From 3e95f1470f7c2be1ed6d8d91adbea2e17135ac83 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 11:52:35 +0500 Subject: [PATCH] =?UTF-8?q?feat(ct):=20=D0=BD=D0=B5=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=9A=D0=9F=20=D0=BE=D0=B4=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D1=89?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20=E2=80=94=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B2=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B5=20?= =?UTF-8?q?=D1=81=D1=80=D0=B0=D0=B2=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Колонка таблицы сравнения теперь «поставщик + файл КП» (id `${spId}_${kpFileSpId}`), а не только поставщик. Два КП одного поставщика (напр. в KZT и USD) раньше схлопывались в одну колонку — оставался последний файл, второе предложение пропадало. Теперь каждый КП — своя колонка со своей валютой; записи в SP различаются по offerFileId: дедуп и чистка повторной обработки не трогают записи другого КП того же поставщика, восстановление по ссылке раскладывает их по колонкам. Старые колонки без файла (данные до изменения) работают как раньше. Этап 1 из 4: выбора «заменить / добавить отдельно» пока нет — второй КП поставщика всегда идёт отдельной колонкой. --- .../forms/ct/components/CtCreateForm.tsx | 38 ++++++++------ .../Step2CommercialOffers.tsx | 7 +-- .../Step2CommercialOffers/UploadedKpList.tsx | 7 +-- .../Step2CommercialOffers/kpCurrency.test.ts | 47 +++++++++++++++++ .../Step2CommercialOffers/kpCurrency.ts | 31 +++++++---- .../step2Persist.test.ts | 52 +++++++------------ .../Step2CommercialOffers/step2Persist.ts | 29 +++++++---- .../Step2CommercialOffers/useKpProcessing.ts | 5 +- .../Step3ComparisonTable/useStep3Matrix.ts | 29 ++++++++--- .../forms/ct/components/types/domain.ts | 3 +- .../components/utils/supplierColumns.test.ts | 48 +++++++++++++++++ .../ct/components/utils/supplierColumns.ts | 45 ++++++++++++++++ .../forms/ct/services/CtService.ts | 14 +++-- .../shared/offers/offerRecords.test.ts | 32 ++++++++++++ .../shared/offers/offerRecords.ts | 18 ++++--- 15 files changed, 306 insertions(+), 99 deletions(-) create mode 100644 src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/kpCurrency.test.ts create mode 100644 src/webparts/ctFormsPortal/forms/ct/components/utils/supplierColumns.test.ts create mode 100644 src/webparts/ctFormsPortal/forms/ct/components/utils/supplierColumns.ts diff --git a/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx b/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx index d4da69c..1e87c39 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/CtCreateForm.tsx @@ -8,6 +8,7 @@ import { Step1PositionSelection } from './Step1PositionSelection/Step1PositionSe import { Step2CommercialOffers } from './Step2CommercialOffers/Step2CommercialOffers'; import { Step3ComparisonTable } from './Step3ComparisonTable/Step3ComparisonTable'; import { Step4Approval } from './Step4Approval/Step4Approval'; +import { buildColumnsFromSpOffers, buildSupplierColumnId, findColumnForRecord } from './utils/supplierColumns'; import styles from './CtCreateForm.module.scss'; import '@tnsu/ui-kit/styles.css'; @@ -85,17 +86,23 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, const rateById = new Map(loadedRates.map(r => [Number(r.id), r])); const spCurrencyRates = loadedRates; - // Build unique suppliers from offers (used only as fallback when session has none) - const supplierCurrencyMap = new Map(); - rawOffers.forEach(o => { - supplierCurrencyMap.set(o.supplierSpId, o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT'); - }); + // Колонки из офферов SP — по одной на (поставщик, файл КП): у поставщика может быть несколько + // КП в разных валютах. Используются, только когда в сессии колонок нет. + const currencyCodeOf = (valutaSpId?: number): string => + valutaSpId ? (rateById.get(valutaSpId)?.code ?? 'KZT') : 'KZT'; + const spSuppliers = buildColumnsFromSpOffers(rawOffers.map(o => ({ + supplierSpId: o.supplierSpId, + supplierName: o.supplierName, + offerFileSpId: o.offerFileSpId, + currencyCode: currencyCodeOf(o.valutaSpId), + }))); const supplierMap = new Map(); rawOffers.forEach(o => { if (!supplierMap.has(o.supplierSpId)) supplierMap.set(o.supplierSpId, o.supplierName); }); - const spSuppliers = Array.from(supplierMap.entries()).map(([spId, name]) => ({ - id: String(spId), spId, name, hasOffer: true as const, - currency: supplierCurrencyMap.get(spId) ?? 'KZT', - })); + // Колонка оффера: из сессии, если она есть (в т.ч. старая колонка без файла КП), иначе — из SP. + const visibleColumns = formData.suppliers.length > 0 ? formData.suppliers : spSuppliers; + const columnIdOf = (supplierSpId: number, offerFileSpId?: number): string => + findColumnForRecord(visibleColumns, supplierSpId, offerFileSpId)?.id + ?? buildSupplierColumnId(supplierSpId, offerFileSpId); // Build step3DeliveryKm from SP offers. Step 3 reads km by React-id key // (`${pos.id}::${s.id}`), so map SP ids → React ids; for delivery the position is an @@ -106,26 +113,23 @@ const CtCreateForm: React.FC = ({ context, userDisplayName, [...formData.selectedPositions, ...(formData.step3ExtraPositions ?? [])].forEach(p => { if (p.spId !== undefined) posReactIdBySpId.set(p.spId, p.id); }); - const supReactIdBySpId = new Map(); - formData.suppliers.forEach(s => { - if (s.spId !== undefined) supReactIdBySpId.set(s.spId, s.id); - }); const step3DeliveryKm: Record = {}; rawOffers.forEach(o => { if (o.deliveryDistanceKm !== undefined) { const posKey = posReactIdBySpId.get(o.positionSpId) ?? String(o.positionSpId); - const supKey = supReactIdBySpId.get(o.supplierSpId) ?? String(o.supplierSpId); + const supKey = columnIdOf(o.supplierSpId, o.offerFileSpId); step3DeliveryKm[`${posKey}::${supKey}`] = o.deliveryDistanceKm; } }); - // Build offers in domain format, restoring offerId from session + // Build offers in domain format. Файл КП берём из самой записи (offerFileId) — карта из сессии + // знает один файл на поставщика и при нескольких КП отдала бы обеим колонкам один и тот же. const offers = rawOffers.map(o => { - const offerIdNum = offerIdBySupplierSpId.get(o.supplierSpId) ?? o.offerFileSpId; + const offerIdNum = o.offerFileSpId ?? offerIdBySupplierSpId.get(o.supplierSpId); const currencyCode = o.valutaSpId ? (rateById.get(o.valutaSpId)?.code ?? 'KZT') : 'KZT'; return { id: '', - supplierId: String(o.supplierSpId), + supplierId: columnIdOf(o.supplierSpId, o.offerFileSpId), positionId: String(o.positionSpId), itemIndex: o.itemIndex, unit: o.unit, diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.tsx index 8b849a2..066e5d9 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.tsx @@ -75,18 +75,19 @@ export const Step2CommercialOffers: React.FC = ({ // Смена валюты уже загруженного КП (список «Загруженные КП»). Если файл ещё в очереди этой сессии, // синхронизируем и её: иначе persistStep2 пересоберёт поставщика из kpFiles со старой валютой. + // Валюта — у конкретного файла: у поставщика может быть несколько КП в разных валютах. const handleUploadedCurrencyChange = (fileSpId: number, currencyCode: string): void => { const uploaded = formData.uploadedKpFiles.find(u => u.spId === fileSpId); if (!uploaded?.supplierName) return; onSave(applySupplierCurrency( formData.suppliers ?? [], formData.offers ?? [], - uploaded.supplierName, + uploaded, currencyCode, currencyRates, )); kp.kpFiles - .filter(f => f.supplierName === uploaded.supplierName) + .filter(f => f.spId === fileSpId) .forEach(f => kp.handleCurrencyChange(f.localId, currencyCode)); }; @@ -152,7 +153,7 @@ export const Step2CommercialOffers: React.FC = ({ files={formData.uploadedKpFiles} service={service} currencyOptions={currencyOptions} - getCurrency={supplierName => getSupplierCurrency(formData.suppliers ?? [], supplierName)} + getCurrency={file => getSupplierCurrency(formData.suppliers ?? [], file)} onCurrencyChange={handleUploadedCurrencyChange} onRemove={(spId) => onSave({ uploadedKpFiles: formData.uploadedKpFiles.filter(u => u.spId !== spId) })} disabled={isNotificationLink || isReadOnly} diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/UploadedKpList.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/UploadedKpList.tsx index e4de8ba..536bbb7 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/UploadedKpList.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/UploadedKpList.tsx @@ -2,13 +2,14 @@ import * as React from 'react'; import { IconButton } from '@tnsu/ui-kit'; import { CtService } from '../../services/CtService'; import { CTFormData } from '../hooks/useCtForm'; +import { KpFileRef } from './kpCurrency'; import styles from './Step2CommercialOffers.module.scss'; interface Props { files: CTFormData['uploadedKpFiles']; service: CtService; currencyOptions: string[]; - getCurrency: (supplierName: string | undefined) => string; + getCurrency: (file: KpFileRef) => string; onCurrencyChange: (spId: number, currencyCode: string) => void; onRemove: (spId: number) => void; disabled?: boolean; @@ -40,12 +41,12 @@ export const UploadedKpList: React.FC = ({ {f.name} {disabled ? ( - {getCurrency(f.supplierName)} + {getCurrency(f)} ) : ( <>