feat(ct): колонки одного поставщика с несколькими КП различаются в шапке, Adata и ИИ
У поставщика с несколькими КП колонки выглядели одинаково: в шапке подписывается файл КП, в «Дополнительной информации», ИИ-комментарии и выборе рекомендованного используется имя «Название (файл)» — ИИ сопоставляет поставщиков по имени и путал одинаковые. Adata проверяет компанию: кэш и результат проверки получают все её колонки, «Проверить всех» делает один платный запрос на компанию, а не на колонку. Этап 3 из 4 (п.2).
This commit is contained in:
+5
@@ -78,6 +78,11 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat
|
||||
<span className={styles.supplierName}>{s.name}</span>
|
||||
{isRec && <span className={styles.recommendedBadge}>Рекомендуется</span>}
|
||||
</div>
|
||||
{matrix.columnLabels[s.id] && (
|
||||
<div className={styles.supplierFileLabel} title={matrix.columnLabels[s.id]}>
|
||||
{matrix.columnLabels[s.id]}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.supplierMetaRow}>
|
||||
<AdataIndicator supplier={s} state={adata.adataMap[s.id]} onCheck={() => void adata.checkSupplier(s.id)} />
|
||||
{currency !== 'KZT' && <span className={styles.currencyTag}>{currency}</span>}
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ interface Props {
|
||||
// Перенесено 1:1 из Step3ComparisonTable (§стайл-гайд: чистая вёрстка, логика — в useStep3Matrix).
|
||||
export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
||||
const {
|
||||
isReadOnly, suppliersWithOffer, criteriaMap, customCriteria, newCriteriaLabel,
|
||||
isReadOnly, suppliersWithOffer, columnDisplayNames, criteriaMap, customCriteria, newCriteriaLabel,
|
||||
setNewCriteriaLabel, updateCriteriaValue, updateCustomCriteriaValue,
|
||||
handleAddCustomCriteria, handleRemoveCustomCriteria,
|
||||
} = matrix;
|
||||
@@ -38,7 +38,7 @@ export function CriteriaTable({ matrix }: Props): JSX.Element | null {
|
||||
<tr>
|
||||
<th className={styles.criteriaLabelHeader} />
|
||||
{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>
|
||||
</thead>
|
||||
|
||||
+11
@@ -170,6 +170,17 @@
|
||||
color: $text;
|
||||
}
|
||||
|
||||
// Файл КП под названием — только у поставщика с несколькими КП (несколько колонок). Длинное имя
|
||||
// файла обрезаем многоточием, полное — в title.
|
||||
.supplierFileLabel {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 11px;
|
||||
color: $muted;
|
||||
}
|
||||
|
||||
.recommendedBadge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
|
||||
+4
-3
@@ -46,7 +46,7 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
||||
const {
|
||||
isReadOnly, newSupplierName, newSupplierCurrency, setNewSupplierName, setNewSupplierCurrency,
|
||||
handleAddSupplier, handleAddSupplierFromDatabase, translateUnits, setTranslateUnits, isSaving, saveError, setSaveError, handleSave,
|
||||
extraPositions, suppliersWithOffer, allPositions, computedOffers, selectedSupplierId,
|
||||
extraPositions, suppliersWithOffer, columnDisplayNames, allPositions, computedOffers, selectedSupplierId,
|
||||
} = matrix;
|
||||
|
||||
// Проверка контрагентов в Adata вынесена в хук.
|
||||
@@ -75,14 +75,15 @@ export const Step3ComparisonTable: React.FC<Props> = ({
|
||||
],
|
||||
suppliers: suppliersWithOffer
|
||||
.map(s => ({
|
||||
name: s.name,
|
||||
// Различимое имя: у поставщика с несколькими КП иначе в тексте будут два одинаковых.
|
||||
name: columnDisplayNames[s.id] ?? s.name,
|
||||
grandTotal: 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(s => s.grandTotal > 0),
|
||||
recommendedSupplier: suppliersWithOffer.find(s => s.id === selectedSupplierId)?.name,
|
||||
recommendedSupplier: selectedSupplierId ? (columnDisplayNames[selectedSupplierId] ?? undefined) : undefined,
|
||||
}),
|
||||
onPdfError: setSaveError,
|
||||
});
|
||||
|
||||
+35
-13
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { CtService } from '../../services/CtService';
|
||||
import { AdataCompanyInfo, Supplier } from '../types/domain';
|
||||
import { findSiblingColumnIds } from '../utils/supplierColumns';
|
||||
|
||||
export interface AdataSupplierState {
|
||||
bin?: string;
|
||||
@@ -38,16 +39,19 @@ export function useAdataCheck(service: CtService, suppliers: Supplier[]): UseAda
|
||||
.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,
|
||||
};
|
||||
suppliersRef.current
|
||||
.filter(s => s.spId === r.supplierSpId)
|
||||
.forEach(sup => {
|
||||
next[sup.id] = {
|
||||
...next[sup.id],
|
||||
bin: r.bin,
|
||||
data: r.adataData,
|
||||
checkedAt: r.adataCheckedAt,
|
||||
isLoading: false,
|
||||
};
|
||||
});
|
||||
});
|
||||
return next;
|
||||
});
|
||||
@@ -63,22 +67,40 @@ export function useAdataCheck(service: CtService, suppliers: Supplier[]): UseAda
|
||||
setAdataMap(prev => ({ ...prev, [supplierId]: { ...prev[supplierId], isLoading: false, error: 'БИН поставщика неизвестен — проверка недоступна' } }));
|
||||
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 {
|
||||
const data = await service.fetchAdataInfo(bin);
|
||||
const checkedAt = new Date().toISOString();
|
||||
setAdataMap(prev => ({ ...prev, [supplierId]: { bin, data, checkedAt, isLoading: false } }));
|
||||
patchColumns(() => ({ 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 } }));
|
||||
patchColumns(prevState => ({ ...prevState, 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));
|
||||
// Одна проверка на компанию: колонки того же поставщика получат результат через 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)));
|
||||
setIsCheckingAll(false);
|
||||
}, [checkSupplier]);
|
||||
|
||||
+4
-3
@@ -30,7 +30,7 @@ export function useRecommendedPick(service: CtService, matrix: Step3Matrix): Use
|
||||
const [pickError, setPickError] = 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
|
||||
&& 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
|
||||
.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
// Различимое имя: ответ ИИ сопоставляется по имени, у поставщика с несколькими КП оно повторяется.
|
||||
name: columnDisplayNames[s.id] ?? 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);
|
||||
@@ -81,7 +82,7 @@ export function useRecommendedPick(service: CtService, matrix: Step3Matrix): Use
|
||||
} finally {
|
||||
setIsPicking(false);
|
||||
}
|
||||
}, [allPaymentTermsFilled, suppliersWithOffer, criteriaMap, computedOffers, allPositions, service, setSelectedSupplierId]);
|
||||
}, [allPaymentTermsFilled, suppliersWithOffer, columnDisplayNames, criteriaMap, computedOffers, allPositions, service, setSelectedSupplierId]);
|
||||
|
||||
return { isPicking, pickError, allPaymentTermsFilled, recommendedId, pickRecommendedSupplier };
|
||||
}
|
||||
|
||||
+14
-1
@@ -6,7 +6,7 @@ import { CTFormData } from '../hooks/useCtForm';
|
||||
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
|
||||
import { applyCreatedIds, buildOfferKey, computeOffer } from './matrixHelpers';
|
||||
import { IVendorRecord } from '../../services/VendorDatabaseService';
|
||||
import { findColumnForRecord } from '../utils/supplierColumns';
|
||||
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
|
||||
|
||||
let extraIdCounter = 0;
|
||||
|
||||
@@ -310,6 +310,17 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
})),
|
||||
], [suppliers, extraSuppliers]);
|
||||
|
||||
// У поставщика может быть несколько КП (колонок) — подписываем их файлом КП, а в текстах и ИИ
|
||||
// используем различимые имена «Название (файл)».
|
||||
const columnLabels = useMemo(
|
||||
() => buildColumnLabels(suppliersWithOffer, formData.uploadedKpFiles ?? []),
|
||||
[suppliersWithOffer, formData.uploadedKpFiles],
|
||||
);
|
||||
const columnDisplayNames = useMemo(
|
||||
() => buildColumnDisplayNames(suppliersWithOffer, columnLabels),
|
||||
[suppliersWithOffer, columnLabels],
|
||||
);
|
||||
|
||||
const allPositions = useMemo((): PositionRow[] => {
|
||||
// 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()));
|
||||
@@ -888,6 +899,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
|
||||
setSaveError,
|
||||
// derived
|
||||
suppliersWithOffer,
|
||||
columnLabels,
|
||||
columnDisplayNames,
|
||||
allPositions,
|
||||
regularPositions,
|
||||
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';
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
// Колонки той же компании (тот же 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user