feat(ct): колонки одного поставщика с несколькими КП различаются в шапке, Adata и ИИ

У поставщика с несколькими КП колонки выглядели одинаково: в шапке подписывается файл КП,
в «Дополнительной информации», ИИ-комментарии и выборе рекомендованного используется имя
«Название (файл)» — ИИ сопоставляет поставщиков по имени и путал одинаковые. Adata проверяет
компанию: кэш и результат проверки получают все её колонки, «Проверить всех» делает один
платный запрос на компанию, а не на колонку.

Этап 3 из 4 (п.2).
This commit is contained in:
Тимур Городецкий
2026-10-01 12:39:02 +05:00
parent 744b603972
commit dc88dfe5ff
9 changed files with 165 additions and 23 deletions
@@ -78,6 +78,11 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
<span className={styles.supplierName}>{s.name}</span> <span className={styles.supplierName}>{s.name}</span>
{isRec && <span className={styles.recommendedBadge}>Рекомендуется</span>} {isRec && <span className={styles.recommendedBadge}>Рекомендуется</span>}
</div> </div>
{matrix.columnLabels[s.id] && (
<div className={styles.supplierFileLabel} title={matrix.columnLabels[s.id]}>
{matrix.columnLabels[s.id]}
</div>
)}
<div className={styles.supplierMetaRow}> <div className={styles.supplierMetaRow}>
<AdataIndicator supplier={s} state={adata.adataMap[s.id]} onCheck={() => void adata.checkSupplier(s.id)} /> <AdataIndicator supplier={s} state={adata.adataMap[s.id]} onCheck={() => void adata.checkSupplier(s.id)} />
{currency !== 'KZT' && <span className={styles.currencyTag}>{currency}</span>} {currency !== 'KZT' && <span className={styles.currencyTag}>{currency}</span>}
@@ -13,7 +13,7 @@ interface Props {
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix). // Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
export function CriteriaTable({ matrix }: Props): JSX.Element | null { export function CriteriaTable({ matrix }: Props): JSX.Element | null {
const { const {
isReadOnly, suppliersWithOffer, criteriaMap, customCriteria, newCriteriaLabel, isReadOnly, suppliersWithOffer, columnDisplayNames, criteriaMap, customCriteria, newCriteriaLabel,
setNewCriteriaLabel, updateCriteriaValue, updateCustomCriteriaValue, setNewCriteriaLabel, updateCriteriaValue, updateCustomCriteriaValue,
handleAddCustomCriteria, handleRemoveCustomCriteria, handleAddCustomCriteria, handleRemoveCustomCriteria,
} = matrix; } = matrix;
@@ -38,7 +38,7 @@ export function CriteriaTable({ matrix }: Props): JSX.Element | null {
<tr> <tr>
<th className={styles.criteriaLabelHeader} /> <th className={styles.criteriaLabelHeader} />
{suppliersWithOffer.map(s => ( {suppliersWithOffer.map(s => (
<th key={s.id} className={styles.criteriaSupplierHeader}>{s.name}</th> <th key={s.id} className={styles.criteriaSupplierHeader}>{columnDisplayNames[s.id] ?? s.name}</th>
))} ))}
</tr> </tr>
</thead> </thead>
@@ -170,6 +170,17 @@
color: $text; color: $text;
} }
// Файл КП под названием — только у поставщика с несколькими КП (несколько колонок). Длинное имя
// файла обрезаем многоточием, полное — в title.
.supplierFileLabel {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
color: $muted;
}
.recommendedBadge { .recommendedBadge {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
@@ -46,7 +46,7 @@ export const Step3ComparisonTable: React.FC<Props> = ({
const { const {
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency, isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave, handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
extraPositions, suppliersWithOffer, allPositions, computedOffers, selectedSupplierId, extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId,
} = matrix; } = matrix;
// Проверка контрагентов в Adata вынесена в хук. // Проверка контрагентов в Adata вынесена в хук.
@@ -75,14 +75,15 @@ export const Step3ComparisonTable: React.FC<Props> = ({
], ],
suppliers: suppliersWithOffer suppliers: suppliersWithOffer
.map(s => ({ .map(s => ({
name: s.name, // Различимое имя: у поставщика с несколькими КП иначе в тексте будут два одинаковых.
name: columnDisplayNames[s.id] ?? s.name,
grandTotal: allPositions.reduce((sum, pos) => { grandTotal: allPositions.reduce((sum, pos) => {
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),
})) }))
.filter(s => s.grandTotal > 0), .filter(s => s.grandTotal > 0),
recommendedSupplier: suppliersWithOffer.find(s => s.id === selectedSupplierId)?.name, recommendedSupplier: selectedSupplierId ? (columnDisplayNames[selectedSupplierId] ?? undefined) : undefined,
}), }),
onPdfError: setSaveError, onPdfError: setSaveError,
}); });
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { CtService } from '../../services/CtService'; import { CtService } from '../../services/CtService';
import { AdataCompanyInfo, Supplier } from '../types/domain'; import { AdataCompanyInfo, Supplier } from '../types/domain';
import { findSiblingColumnIds } from '../utils/supplierColumns';
export interface AdataSupplierState { export interface AdataSupplierState {
bin?: string; bin?: string;
@@ -38,16 +39,19 @@ export function useAdataCheck(service: CtService, suppliers: Supplier[]): UseAda
.then(rows => { .then(rows => {
setAdataMap(prev => { setAdataMap(prev => {
const next = { ...prev }; const next = { ...prev };
// Кэш — на компанию: у поставщика с несколькими КП он нужен каждой его колонке.
rows.forEach(r => { rows.forEach(r => {
const sup = suppliersRef.current.find(s => s.spId === r.supplierSpId); suppliersRef.current
if (!sup) return; .filter(s => s.spId === r.supplierSpId)
next[sup.id] = { .forEach(sup => {
...next[sup.id], next[sup.id] = {
bin: r.bin, ...next[sup.id],
data: r.adataData, bin: r.bin,
checkedAt: r.adataCheckedAt, data: r.adataData,
isLoading: false, checkedAt: r.adataCheckedAt,
}; isLoading: false,
};
});
}); });
return next; return next;
}); });
@@ -63,22 +67,40 @@ export function useAdataCheck(service: CtService, suppliers: Supplier[]): UseAda
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], isLoading: false, error: 'БИН поставщика неизвестен — проверка недоступна' } })); setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], isLoading: false, error: 'БИН поставщика неизвестен — проверка недоступна' } }));
return; return;
} }
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], bin, isLoading: true, error: undefined } })); // Adata проверяет компанию — результат раздаём всем колонкам этого поставщика (несколько КП).
const columnIds = findSiblingColumnIds(suppliersRef.current, supplierId);
const patchColumns = (state: (prevState: AdataSupplierState | undefined) => AdataSupplierState): void => {
setAdataMap(prev => {
const next = { ...prev };
columnIds.forEach(id => { next[id] = state(prev[id]); });
return next;
});
};
patchColumns(prevState => ({ ...prevState, bin, isLoading: true, error: undefined }));
try { try {
const data = await service.fetchAdataInfo(bin); const data = await service.fetchAdataInfo(bin);
const checkedAt = new Date().toISOString(); const checkedAt = new Date().toISOString();
setAdataMap(prev => ({ ...prev, [supplierId]: { bin, data, checkedAt, isLoading: false } })); patchColumns(() => ({ bin, data, checkedAt, isLoading: false }));
if (sup.spId !== undefined) { if (sup.spId !== undefined) {
service.saveAdataResult(sup.spId, data).catch(() => undefined); service.saveAdataResult(sup.spId, data).catch(() => undefined);
} }
} catch (err) { } catch (err) {
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], bin, isLoading: false, error: (err as Error).message } })); patchColumns(prevState => ({ ...prevState, bin, isLoading: false, error: (err as Error).message }));
} }
}, [service]); }, [service]);
const checkAllSuppliers = useCallback(async (): Promise<void> => { const checkAllSuppliers = useCallback(async (): Promise<void> => {
setIsCheckingAll(true); setIsCheckingAll(true);
const targets = suppliersRef.current.filter(s => s.hasOffer && (adataMapRef.current[s.id]?.bin ?? s.bin)); // Одна проверка на компанию: колонки того же поставщика получат результат через checkSupplier.
const checkedSpIds = new Set<number>();
const targets = suppliersRef.current
.filter(s => s.hasOffer && (adataMapRef.current[s.id]?.bin ?? s.bin))
.filter(s => {
if (s.spId === undefined) return true;
if (checkedSpIds.has(s.spId)) return false;
checkedSpIds.add(s.spId);
return true;
});
await Promise.all(targets.map(s => checkSupplier(s.id))); await Promise.all(targets.map(s => checkSupplier(s.id)));
setIsCheckingAll(false); setIsCheckingAll(false);
}, [checkSupplier]); }, [checkSupplier]);
@@ -30,7 +30,7 @@ export function useRecommendedPick(service: CtService, matrix: Step3Matrix): Use
const [pickError, setPickError] = useState<string | undefined>(undefined); const [pickError, setPickError] = useState<string | undefined>(undefined);
const [recommendedId, setRecommendedId] = useState<string | undefined>(undefined); const [recommendedId, setRecommendedId] = useState<string | undefined>(undefined);
const { suppliersWithOffer, criteriaMap, computedOffers, allPositions, setSelectedSupplierId } = matrix; const { suppliersWithOffer, columnDisplayNames, criteriaMap, computedOffers, allPositions, setSelectedSupplierId } = matrix;
const allPaymentTermsFilled = suppliersWithOffer.length > 0 const allPaymentTermsFilled = suppliersWithOffer.length > 0
&& suppliersWithOffer.every(s => (criteriaMap[s.id]?.paymentType ?? '').trim().length > 0); && suppliersWithOffer.every(s => (criteriaMap[s.id]?.paymentType ?? '').trim().length > 0);
@@ -43,7 +43,8 @@ export function useRecommendedPick(service: CtService, matrix: Step3Matrix): Use
const totals = suppliersWithOffer const totals = suppliersWithOffer
.map(s => ({ .map(s => ({
id: s.id, id: s.id,
name: s.name, // Различимое имя: ответ ИИ сопоставляется по имени, у поставщика с несколькими КП оно повторяется.
name: columnDisplayNames[s.id] ?? s.name,
paymentType: criteriaMap[s.id]?.paymentType ?? '', paymentType: criteriaMap[s.id]?.paymentType ?? '',
total: allPositions.reduce((sum, pos) => { total: allPositions.reduce((sum, pos) => {
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);
@@ -81,7 +82,7 @@ export function useRecommendedPick(service: CtService, matrix: Step3Matrix): Use
} finally { } finally {
setIsPicking(false); setIsPicking(false);
} }
}, [allPaymentTermsFilled, suppliersWithOffer, criteriaMap, computedOffers, allPositions, service, setSelectedSupplierId]); }, [allPaymentTermsFilled, suppliersWithOffer, columnDisplayNames, criteriaMap, computedOffers, allPositions, service, setSelectedSupplierId]);
return { isPicking, pickError, allPaymentTermsFilled, recommendedId, pickRecommendedSupplier }; return { isPicking, pickError, allPaymentTermsFilled, recommendedId, pickRecommendedSupplier };
} }
@@ -6,7 +6,7 @@ import { CTFormData } from '../hooks/useCtForm';
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes'; import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
import { applyCreatedIds, buildOfferKey, computeOffer } from './matrixHelpers'; import { applyCreatedIds, buildOfferKey, computeOffer } from './matrixHelpers';
import { IVendorRecord } from '../../services/VendorDatabaseService'; import { IVendorRecord } from '../../services/VendorDatabaseService';
import { findColumnForRecord } from '../utils/supplierColumns'; import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
let extraIdCounter = 0; let extraIdCounter = 0;
@@ -310,6 +310,17 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
})), })),
], [suppliers, extraSuppliers]); ], [suppliers, extraSuppliers]);
// У поставщика может быть несколько КП (колонок) — подписываем их файлом КП, а в текстах и ИИ
// используем различимые имена «Название (файл)».
const columnLabels = useMemo(
() => buildColumnLabels(suppliersWithOffer, formData.uploadedKpFiles ?? []),
[suppliersWithOffer, formData.uploadedKpFiles],
);
const columnDisplayNames = useMemo(
() => buildColumnDisplayNames(suppliersWithOffer, columnLabels),
[suppliersWithOffer, columnLabels],
);
const allPositions = useMemo((): PositionRow[] => { const allPositions = useMemo((): PositionRow[] => {
// Extra positions take priority: if a selected position shares a name with an extra, hide it from the regular section // Extra positions take priority: if a selected position shares a name with an extra, hide it from the regular section
const extraNames = new Set(extraPositions.map(ep => ep.materialName.toLowerCase().trim())); const extraNames = new Set(extraPositions.map(ep => ep.materialName.toLowerCase().trim()));
@@ -888,6 +899,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
setSaveError, setSaveError,
// derived // derived
suppliersWithOffer, suppliersWithOffer,
columnLabels,
columnDisplayNames,
allPositions, allPositions,
regularPositions, regularPositions,
extraPositionsRows, extraPositionsRows,
@@ -1,4 +1,4 @@
import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './supplierColumns'; import { buildColumnDisplayNames, buildColumnLabels, buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord, findSiblingColumnIds } from './supplierColumns';
import { Supplier } from '../types/domain'; import { Supplier } from '../types/domain';
describe('buildSupplierColumnId', () => { describe('buildSupplierColumnId', () => {
@@ -46,3 +46,49 @@ describe('buildColumnsFromSpOffers (открытие по ссылке / без
]); ]);
}); });
}); });
describe('подписи колонок поставщика с несколькими КП', () => {
const ARLAN = 'ТОО «Арлан Офис Снаб»';
const columns: Supplier[] = [
{ id: '263_277', spId: 263, kpFileSpId: 277, name: ARLAN, hasOffer: true },
{ id: '264_278', spId: 264, kpFileSpId: 278, name: 'ТОО «Кала Саплай»', hasOffer: true },
{ id: '263_285', spId: 263, kpFileSpId: 285, name: ARLAN, hasOffer: true },
];
const uploaded = [
{ spId: 277, name: 'KP_01_Arlan_Office_KZT.pdf' },
{ spId: 278, name: 'KP_02_Qala_Supply_KZT.pdf' },
{ spId: 285, name: 'KP_01_Arlan_Office_USD.pdf' },
];
// Две колонки «Арлан» в шапке неотличимы — подписываем файлом КП, но только их.
it('buildColumnLabels: файл КП — только у колонок поставщика с несколькими КП', () => {
expect(buildColumnLabels(columns, uploaded)).toEqual({
'263_277': 'KP_01_Arlan_Office_KZT.pdf',
'263_285': 'KP_01_Arlan_Office_USD.pdf',
});
});
it('buildColumnLabels: файл неизвестен — номер КП по порядку', () => {
expect(buildColumnLabels(columns, [])).toEqual({ '263_277': 'КП 1', '263_285': 'КП 2' });
});
// ИИ-комментарий и выбор рекомендованного сопоставляют поставщиков по имени — имена должны различаться.
it('buildColumnDisplayNames: имя с подписью для повторяющихся, просто имя для остальных', () => {
const names = buildColumnDisplayNames(columns, buildColumnLabels(columns, uploaded));
expect(names).toEqual({
'263_277': `${ARLAN} (KP_01_Arlan_Office_KZT.pdf)`,
'264_278': 'ТОО «Кала Саплай»',
'263_285': `${ARLAN} (KP_01_Arlan_Office_USD.pdf)`,
});
});
// Adata проверяет компанию: результат нужен всем её колонкам, а платный запрос — один.
it('findSiblingColumnIds: все колонки той же компании', () => {
expect(findSiblingColumnIds(columns, '263_285')).toEqual(['263_277', '263_285']);
});
it('findSiblingColumnIds: поставщик без SP Id — только сама колонка', () => {
expect(findSiblingColumnIds([{ id: 'extra_sup_1', name: 'Ручной', hasOffer: true }], 'extra_sup_1')).toEqual(['extra_sup_1']);
});
});
@@ -43,3 +43,46 @@ export function buildColumnsFromSpOffers(records: SpOfferColumnSource[]): Suppli
}); });
return Array.from(columnsById.values()); return Array.from(columnsById.values());
} }
// Колонки той же компании (тот же SP Id поставщика), включая саму колонку. Нужно для данных, которые
// принадлежат компании, а не КП: проверка Adata, БИН.
export function findSiblingColumnIds(columns: Supplier[], columnId: string): string[] {
const column = columns.find(c => c.id === columnId);
if (column?.spId === undefined) return [columnId];
return columns.filter(c => c.spId === column.spId).map(c => c.id);
}
// Подписи колонок поставщика с несколькими КП — имя файла КП (без файла — «КП N» по порядку).
// У поставщика с одним КП подписи нет: в шапке хватает названия.
export function buildColumnLabels(
columns: Supplier[],
uploadedKpFiles: Array<{ spId: number; name: string }>,
): Record<string, string> {
const labels: Record<string, string> = {};
const columnsBySupplier = new Map<number, Supplier[]>();
columns.forEach(c => {
if (c.spId === undefined) return;
columnsBySupplier.set(c.spId, [...(columnsBySupplier.get(c.spId) ?? []), c]);
});
columnsBySupplier.forEach(group => {
if (group.length < 2) return;
group.forEach((c, index) => {
const fileName = uploadedKpFiles.find(f => f.spId === c.kpFileSpId)?.name;
labels[c.id] = fileName ?? `КП ${index + 1}`;
});
});
return labels;
}
// Различимые имена колонок для текстов и ИИ (комментарий, выбор рекомендованного), где поставщиков
// сопоставляют по имени: «Название (файл КП)» у повторяющихся, просто название у остальных.
export function buildColumnDisplayNames(
columns: Supplier[],
labels: Record<string, string>,
): Record<string, string> {
const names: Record<string, string> = {};
columns.forEach(c => {
names[c.id] = labels[c.id] ? `${c.name} (${labels[c.id]})` : c.name;
});
return names;
}