From c1a5c6fde7f82c6dab8e994af28a01f57254aebc 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: Wed, 2 Sep 2026 11:35:08 +0500 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= =?UTF-8?q?=20=D1=88=D0=B0=D0=B3=D0=BE=D0=B2=202=20=D0=B8=204=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D1=85=D0=BB=D0=B5=D0=B1=D0=BD=D1=8B=D0=BC=D0=B8=20?= =?UTF-8?q?=D0=BA=D1=80=D0=BE=D1=88=D0=BA=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Навигация через goToStep (хлебные крошки) переключает шаг, не вызывая per-step save, поэтому локальный стейт, который писался в formData только по кнопке «Сохранить и продолжить», терялся при переходе на другой шаг. - Шаг 2: поставщики без КП теперь гидратируются из formData и пишутся в formData.suppliers сразу при изменении (а не только в persistStep2). - Шаг 2: курсы валют пишутся в formData при каждой правке. - Шаг 2: поставщик с КП и его офферы (с валютой файла) пишутся в formData инкрементально при завершении обработки КП, иначе загруженный после первого сохранения поставщик не попадал колонкой в таблицу шага 3. Построение поставщиков/офферов вынесено в общую buildKpSuppliersAndOffers. - Шаг 4: переупорядочивание согласующих (moveMember) пишет порядок в formData сразу, иначе он сбрасывался при переходе на другой шаг. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Step2CommercialOffers.tsx | 35 +++++++- .../Step2CommercialOffers/step2Persist.ts | 89 +++++++++++-------- .../Step2CommercialOffers/useKpProcessing.ts | 25 +++++- .../Step4Approval/useStep4Approval.ts | 23 ++--- 4 files changed, 116 insertions(+), 56 deletions(-) diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.tsx index 781dec5..2384398 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/Step2CommercialOffers.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { useState } from 'react'; import { Button, Alert, Spinner } from '@tnsu/ui-kit'; -import { CurrencyRate, FormStep } from '../types/domain'; +import { CurrencyRate, FormStep, Supplier } from '../types/domain'; import { CtService } from '../../services/CtService'; import { CTFormData } from '../hooks/useCtForm'; import { FileUploadArea, UploadedFile } from './FileUploadArea'; @@ -41,12 +41,39 @@ export const Step2CommercialOffers: React.FC = ({ isRestrictedView = false, }) => { const [currencyRates, setCurrencyRates] = useState(formData.currencyRates); - const [noOfferSuppliers, setNoOfferSuppliers] = useState([]); + // Гидратируем из уже сохранённых поставщиков без КП, чтобы список не был пустым при возврате на шаг. + const [noOfferSuppliers, setNoOfferSuppliers] = useState(() => + (formData.suppliers ?? []) + .filter(s => !s.hasOffer) + .map(s => ({ localId: s.id, name: s.name, comment: '' })), + ); const [isSaving, setIsSaving] = useState(false); const [saveError, setSaveError] = useState(undefined); const kp = useKpProcessing(service, formData, onSave); + // Курсы валют пишем в formData сразу, а не только по кнопке «Сохранить и продолжить»: + // иначе при переходе на Шаг 3 через хлебные крошки (в обход persistStep2) правка курса + // теряется, и таблица сравнения считает суммы по старому курсу. + const handleCurrencyRatesChange = (rates: CurrencyRate[]): void => { + setCurrencyRates(rates); + onSave({ currencyRates: rates }); + }; + + // Поставщиков без КП сразу пишем в formData.suppliers (hasOffer:false), а не только по кнопке + // «Сохранить и продолжить». Иначе при переходе на Шаг 3 через хлебные крошки (goToStep в обход + // persistStep2) колонка поставщика без КП теряется и не попадает в таблицу сравнения. + const handleNoOfferChange = (items: NoOfferEntry[]): void => { + setNoOfferSuppliers(items); + const withOffer = (formData.suppliers ?? []).filter(s => s.hasOffer); + const withoutOffer: Supplier[] = items.map(s => ({ + id: s.localId, + name: s.name, + hasOffer: false, + })); + onSave({ suppliers: [...withOffer, ...withoutOffer] }); + }; + const handleSave = async (): Promise => { setIsSaving(true); setSaveError(undefined); @@ -98,11 +125,11 @@ export const Step2CommercialOffers: React.FC = ({

Курсы валют

- +
- + {!isRestrictedView && (formData.uploadedKpFiles?.length ?? 0) > 0 && ( f.supplierName) + .map(f => ({ + id: f.supplierSpId ? String(f.supplierSpId) : f.localId, + spId: f.supplierSpId, + name: f.supplierName as string, + hasOffer: true, + currency: f.currencyCode, + })); + + const offers: SupplierOffer[] = []; + for (const f of kpFiles) { + if (!f.supplierSpId || f.flowResults.length === 0) continue; + const currency = f.currencyCode; + const rate = currency !== 'KZT' + ? (currencyRates.find(r => r.code === currency)?.rateToKzt ?? 1) + : 1; + for (const fr of f.flowResults) { + const pricePerUnit = fr.flowResult.price ?? 0; + const quantity = fr.flowResult.quantity ?? 0; + const pricePerUnitKzt = pricePerUnit * rate; + const totalPriceKzt = pricePerUnitKzt * quantity; + offers.push({ + id: '', + supplierId: String(f.supplierSpId), + positionId: fr.positionId, + supplierPosition: fr.flowResult.position, + unit: fr.flowResult.unit, + pricePerUnit, + quantity, + currencyCode: currency, + pricePerUnitKzt, + totalPriceKzt, + totalWithExtraKzt: totalPriceKzt, + offerId: f.spId ? String(f.spId) : undefined, + }); + } + } + + return { suppliers, offers }; +} + // Сохранение Шага 2: курсы валют, поставщики без КП, поставщики с КП + офферы из результатов потока, // пересборка цепочки согласования из матрицы. Возвращает patch для onSave. Логика 1:1 из исходного handleSave. export async function persistStep2(args: PersistStep2Args): Promise> { @@ -26,15 +75,7 @@ export async function persistStep2(args: PersistStep2Args): Promise f.supplierName) - .map(f => ({ - id: f.supplierSpId ? String(f.supplierSpId) : f.localId, - spId: f.supplierSpId, - name: f.supplierName as string, - hasOffer: true, - currency: f.currencyCode, - })); + const { suppliers: newSuppliersWithOffer, offers: newOffers } = buildKpSuppliersAndOffers(kpFiles, savedRates); const suppliersWithoutOffer: Supplier[] = noOfferSuppliers.map(s => ({ id: s.localId, @@ -42,36 +83,6 @@ export async function persistStep2(args: PersistStep2Args): Promise r.code === currency)?.rateToKzt ?? 1) - : 1; - for (const fr of f.flowResults) { - const pricePerUnit = fr.flowResult.price ?? 0; - const quantity = fr.flowResult.quantity ?? 0; - const pricePerUnitKzt = pricePerUnit * rate; - const totalPriceKzt = pricePerUnitKzt * quantity; - newOffers.push({ - id: '', - supplierId: String(f.supplierSpId), - positionId: fr.positionId, - supplierPosition: fr.flowResult.position, - unit: fr.flowResult.unit, - pricePerUnit, - quantity, - currencyCode: currency, - pricePerUnitKzt, - totalPriceKzt, - totalWithExtraKzt: totalPriceKzt, - offerId: f.spId ? String(f.spId) : undefined, - }); - } - } - // Persist offers with currency data to SP so shared links show correct currency columns. // saveKpOffer (called during flow) omits valutaId and stores price as priceTenge — fix here // before proceeding to Step 3. diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpProcessing.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpProcessing.ts index 97fc4ac..32d3fee 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpProcessing.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/useKpProcessing.ts @@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { CtService } from '../../services/CtService'; import { CTFormData } from '../hooks/useCtForm'; import { KpFile, FlowPositionResult, POLL_INTERVAL_MS, MAX_POLL_RETRIES } from './kpTypes'; +import { buildKpSuppliersAndOffers } from './step2Persist'; let localIdCounter = 0; @@ -34,17 +35,35 @@ export function useKpProcessing( useEffect(() => { kpFilesRef.current = kpFiles; }, [kpFiles]); useEffect(() => () => { mountedRef.current = false; }, []); - // Сохраняем завершённые файлы в formData при изменении статуса + // Сохраняем завершённые файлы в formData при изменении статуса: список загруженных КП, а также + // поставщиков с офферами. Иначе поставщик, чей КП загрузили после первого сохранения Шага 2, + // не доходит до formData.suppliers и не появляется колонкой в таблице сравнения на Шаге 3 + // (переход крошкой минует persistStep2). Валюта, выбранная для файла, тоже попадает сюда. useEffect(() => { const doneFiles = kpFiles.filter(f => f.status === 'done' && f.spId !== undefined); if (doneFiles.length === 0) return; - const currentUploaded = formDataRef.current.uploadedKpFiles ?? []; + const fd = formDataRef.current; + const patch: Partial = {}; + + const currentUploaded = fd.uploadedKpFiles ?? []; const newEntries = doneFiles .filter(f => !currentUploaded.some(u => u.spId === f.spId)) .map(f => ({ name: f.file.name, spId: f.spId as number, supplierName: f.supplierName })); if (newEntries.length > 0) { - onSave({ uploadedKpFiles: [...currentUploaded, ...newEntries] }); + patch.uploadedKpFiles = [...currentUploaded, ...newEntries]; } + + const { suppliers: kpSuppliers, offers: kpOffers } = buildKpSuppliersAndOffers(doneFiles, fd.currencyRates); + if (kpSuppliers.length > 0) { + // Заменяем поставщика с тем же именем (повторная загрузка КП), остальных — сохраняем. + const others = (fd.suppliers ?? []).filter(s => !kpSuppliers.some(k => k.name === s.name)); + patch.suppliers = [...others, ...kpSuppliers]; + const kpSupplierIds = new Set(kpSuppliers.map(s => s.id)); + const otherOffers = (fd.offers ?? []).filter(o => !kpSupplierIds.has(o.supplierId)); + patch.offers = [...otherOffers, ...kpOffers]; + } + + if (Object.keys(patch).length > 0) onSave(patch); // eslint-disable-next-line react-hooks/exhaustive-deps }, [kpFiles]); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/useStep4Approval.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/useStep4Approval.ts index 101c72f..e3512f9 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/useStep4Approval.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step4Approval/useStep4Approval.ts @@ -60,16 +60,19 @@ export function useStep4Approval(args: UseStep4ApprovalArgs) { const replaceTimerRef = useRef | null>(null); const moveMember = useCallback((id: string, direction: 'up' | 'down'): void => { - setChain((prev) => { - const idx = prev.findIndex((m) => m.id === id); - if (idx === -1) return prev; - const next = [...prev]; - const swapIdx = direction === 'up' ? idx - 1 : idx + 1; - if (swapIdx < 0 || swapIdx >= next.length) return prev; - [next[idx], next[swapIdx]] = [next[swapIdx], next[idx]]; - return next.map((m, i) => ({ ...m, order: i + 1 })); - }); - }, []); + const idx = chain.findIndex((m) => m.id === id); + if (idx === -1) return; + const swapIdx = direction === 'up' ? idx - 1 : idx + 1; + if (swapIdx < 0 || swapIdx >= chain.length) return; + const next = [...chain]; + [next[idx], next[swapIdx]] = [next[swapIdx], next[idx]]; + const reordered = next.map((m, i) => ({ ...m, order: i + 1 })); + setChain(reordered); + // Пишем порядок в formData сразу, иначе переупорядочивание теряется при переходе на другой шаг + // (chain реинициализируется из formData.approvalChain при возврате). В SP порядок фиксируется + // на отправке на согласование (activateApprovalChain). + onSave({ approvalChain: reordered }); + }, [chain, onSave]); const openActionDialog = useCallback((member: ApprovalMember, action: ApprovalAction): void => { setActionDialog({