From dc88dfe5fff41a5e8c388760aaa39bda07582247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Thu, 1 Oct 2026 12:39:02 +0500 Subject: [PATCH] =?UTF-8?q?feat(ct):=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D1=89=D0=B8=D0=BA=D0=B0=20=D1=81?= =?UTF-8?q?=20=D0=BD=D0=B5=D1=81=D0=BA=D0=BE=D0=BB=D1=8C=D0=BA=D0=B8=D0=BC?= =?UTF-8?q?=D0=B8=20=D0=9A=D0=9F=20=D1=80=D0=B0=D0=B7=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D0=B2=20=D1=88=D0=B0=D0=BF?= =?UTF-8?q?=D0=BA=D0=B5,=20Adata=20=D0=B8=20=D0=98=D0=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit У поставщика с несколькими КП колонки выглядели одинаково: в шапке подписывается файл КП, в «Дополнительной информации», ИИ-комментарии и выборе рекомендованного используется имя «Название (файл)» — ИИ сопоставляет поставщиков по имени и путал одинаковые. Adata проверяет компанию: кэш и результат проверки получают все её колонки, «Проверить всех» делает один платный запрос на компанию, а не на колонку. Этап 3 из 4 (п.2). --- .../Step3ComparisonTable/ComparisonMatrix.tsx | 5 ++ .../Step3ComparisonTable/CriteriaTable.tsx | 4 +- .../Step3ComparisonTable.module.scss | 11 +++++ .../Step3ComparisonTable.tsx | 7 +-- .../Step3ComparisonTable/useAdataCheck.ts | 48 ++++++++++++++----- .../useRecommendedPick.ts | 7 +-- .../Step3ComparisonTable/useStep3Matrix.ts | 15 +++++- .../components/utils/supplierColumns.test.ts | 48 ++++++++++++++++++- .../ct/components/utils/supplierColumns.ts | 43 +++++++++++++++++ 9 files changed, 165 insertions(+), 23 deletions(-) diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ComparisonMatrix.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ComparisonMatrix.tsx index 5385052..930a10a 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ComparisonMatrix.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/ComparisonMatrix.tsx @@ -78,6 +78,11 @@ export function ComparisonMatrix({ matrix, currencyRates, isRestrictedView, adat {s.name} {isRec && Рекомендуется} + {matrix.columnLabels[s.id] && ( +
+ {matrix.columnLabels[s.id]} +
+ )}
void adata.checkSupplier(s.id)} /> {currency !== 'KZT' && {currency}} diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/CriteriaTable.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/CriteriaTable.tsx index 92985cb..274d900 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/CriteriaTable.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/CriteriaTable.tsx @@ -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 { {suppliersWithOffer.map(s => ( - {s.name} + {columnDisplayNames[s.id] ?? s.name} ))} diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.module.scss b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.module.scss index 5922478..162b95e 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.module.scss +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.module.scss @@ -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; diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.tsx index 8f70504..3509ec1 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/Step3ComparisonTable.tsx @@ -46,7 +46,7 @@ export const Step3ComparisonTable: React.FC = ({ 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 = ({ ], 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, }); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useAdataCheck.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useAdataCheck.ts index e1fc854..07c387d 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useAdataCheck.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useAdataCheck.ts @@ -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 => { setIsCheckingAll(true); - const targets = suppliersRef.current.filter(s => s.hasOffer && (adataMapRef.current[s.id]?.bin ?? s.bin)); + // Одна проверка на компанию: колонки того же поставщика получат результат через checkSupplier. + const checkedSpIds = new Set(); + 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]); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useRecommendedPick.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useRecommendedPick.ts index 99f03a4..fa1f61b 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useRecommendedPick.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useRecommendedPick.ts @@ -30,7 +30,7 @@ export function useRecommendedPick(service: CtService, matrix: Step3Matrix): Use const [pickError, setPickError] = useState(undefined); const [recommendedId, setRecommendedId] = useState(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 }; } diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts index 432099c..0edce42 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts @@ -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, diff --git a/src/webparts/ctFormsPortal/forms/ct/components/utils/supplierColumns.test.ts b/src/webparts/ctFormsPortal/forms/ct/components/utils/supplierColumns.test.ts index f12137c..b89def1 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/utils/supplierColumns.test.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/utils/supplierColumns.test.ts @@ -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']); + }); +}); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/utils/supplierColumns.ts b/src/webparts/ctFormsPortal/forms/ct/components/utils/supplierColumns.ts index d5ddfbb..37263e7 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/utils/supplierColumns.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/utils/supplierColumns.ts @@ -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 { + const labels: Record = {}; + const columnsBySupplier = new Map(); + 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, +): Record { + const names: Record = {}; + columns.forEach(c => { + names[c.id] = labels[c.id] ? `${c.name} (${labels[c.id]})` : c.name; + }); + return names; +}