From 6ad40bc52329a7c5943788aa3abeacd2a5f20809 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: Tue, 27 Oct 2026 15:00:05 +0500 Subject: [PATCH] =?UTF-8?q?fix(ct):=20=D1=86=D0=B5=D0=BD=D0=B0=20=D1=81=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B1=D0=B5=D0=BB=D0=B0=D0=BC=D0=B8=20=D0=BC?= =?UTF-8?q?=D0=B5=D0=B6=D0=B4=D1=83=20=D1=80=D0=B0=D0=B7=D1=80=D1=8F=D0=B4?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8=20=D1=81=D1=87=D0=B8=D1=82=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D1=86=D0=B5=D0=BB=D0=B8=D0=BA=D0=BE=D0=BC=20(#6?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ввод чисел разбирался parseFloat, который читает до первого пробела: цену «100 000», скопированную из КП, таблица считала как 100. Все поля ввода СТ (цена, цена в ₸, количество, километраж, курс валюты) разбирает одна функция: обычные и неразрывные пробелы, «275 293,50», «1,234,567.89», хвост «₸». Co-Authored-By: Claude Opus 5.5 (1M context) --- .../CurrencyRatesForm.tsx | 5 +- .../Step3ComparisonTable/MatrixRowView.tsx | 3 +- .../Step3ComparisonTable/useStep3Matrix.ts | 21 ++++---- .../components/utils/parseNumberInput.test.ts | 52 +++++++++++++++++++ .../ct/components/utils/parseNumberInput.ts | 32 ++++++++++++ 5 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 src/webparts/ctFormsPortal/forms/ct/components/utils/parseNumberInput.test.ts create mode 100644 src/webparts/ctFormsPortal/forms/ct/components/utils/parseNumberInput.ts diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/CurrencyRatesForm.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/CurrencyRatesForm.tsx index e8e6430..dc46248 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/CurrencyRatesForm.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step2CommercialOffers/CurrencyRatesForm.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { useCallback, useState } from 'react'; import { Button, Alert, TextInput, IconButton, Spinner } from '@tnsu/ui-kit'; import { CurrencyRate } from '../types/domain'; +import { parseNumberInput } from '../utils/parseNumberInput'; import styles from './CurrencyRatesForm.module.scss'; interface Props { @@ -58,9 +59,9 @@ export const CurrencyRatesForm: React.FC = ({ rates, onChange, readOnly = const updateRate = useCallback( (code: string, value: string) => { - const parsed = parseFloat(value.replace(',', '.')); + const parsed = parseNumberInput(value); onChange( - rates.map(r => (r.code === code ? { ...r, rateToKzt: isNaN(parsed) ? 0 : parsed } : r)) + rates.map(r => (r.code === code ? { ...r, rateToKzt: parsed ?? 0 } : r)) ); }, [rates, onChange] diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/MatrixRowView.tsx b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/MatrixRowView.tsx index 260872e..e35ca8d 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/MatrixRowView.tsx +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/MatrixRowView.tsx @@ -4,6 +4,7 @@ import { CurrencyRate } from '../types/domain'; import { PositionRow } from './matrixTypes'; import { Step3Matrix } from './useStep3Matrix'; import { getUnitStatus } from '../utils/unitConversion'; +import { parseNumberInput } from '../utils/parseNumberInput'; import { unitStatusClass } from './unitStatusStyles'; import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers'; import styles from './Step3ComparisonTable.module.scss'; @@ -55,7 +56,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict /> ) : ( - posQtyOverrides[pos.id] !== undefined ? parseFloat(posQtyOverrides[pos.id]) : pos.quantity + (posQtyOverrides[pos.id] !== undefined ? parseNumberInput(posQtyOverrides[pos.id]) : undefined) ?? pos.quantity )} diff --git a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts index 1be83e7..e14ba3e 100644 --- a/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts +++ b/src/webparts/ctFormsPortal/forms/ct/components/Step3ComparisonTable/useStep3Matrix.ts @@ -6,6 +6,7 @@ import { CTFormData } from '../hooks/useCtForm'; import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes'; import { applyCreatedIds, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers'; import { buildExcludedRecordsForSave } from '../utils/excludedColumns'; +import { parseNumberInput } from '../utils/parseNumberInput'; import { IVendorRecord } from '../../services/VendorDatabaseService'; import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns'; import { getNoOfferSuppliers } from '../utils/noOfferSuppliers'; @@ -275,8 +276,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) { }); const qtyOverrides: Record = {}; Object.keys(posQtyOverridesRef.current).forEach(id => { - const v = parseFloat(posQtyOverridesRef.current[id]); - if (!isNaN(v)) qtyOverrides[id] = v; + const v = parseNumberInput(posQtyOverridesRef.current[id]); + if (v !== undefined) qtyOverrides[id] = v; }); onSaveRef.current({ offers: updatedOffers, @@ -399,8 +400,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) { setEdits(prev => { const pos = allPositionsRef.current.find(p => p.id === positionId); const items = prev[key] ?? [{ supplierId, positionId, itemIndex: rowIdx, quantity: pos?.quantity || undefined, unit: pos?.unit, supplierProductName: pos?.materialName }]; - const parsed = parseFloat(value.replace(',', '.')); - const numVal = isNaN(parsed) ? 0 : Math.round(parsed * 1000) / 1000; + const parsed = parseNumberInput(value); + const numVal = parsed === undefined ? 0 : Math.round(parsed * 1000) / 1000; return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, [field]: numVal } : it) }; }); }, @@ -416,8 +417,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) { setEdits(prev => { const pos = allPositionsRef.current.find(p => p.id === positionId); const items = prev[key] ?? [{ supplierId, positionId, itemIndex: rowIdx, quantity: pos?.quantity || undefined, unit: pos?.unit, supplierProductName: pos?.materialName }]; - const parsed = parseFloat(value.replace(',', '.')); - const override = isNaN(parsed) ? undefined : Math.round(parsed * 1000) / 1000; + const parsed = parseNumberInput(value); + const override = parsed === undefined ? undefined : Math.round(parsed * 1000) / 1000; return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, priceKztOverride: override } : it) }; }); }, @@ -661,9 +662,9 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) { }; const handleDeliveryKmChange = (posId: string, supId: string, val: string): void => { - const km = parseFloat(val); + const km = parseNumberInput(val); const key = `${posId}::${supId}`; - const updated = isNaN(km) + const updated = km === undefined ? (({ [key]: _, ...rest }) => rest)(deliveryKm) : { ...deliveryKm, [key]: km }; setDeliveryKm(updated); @@ -894,8 +895,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) { spId: extraPosSpIds[ep.id] ?? ep.spId, })), step3PositionQtyOverrides: Object.keys(posQtyOverrides).reduce>((acc, id) => { - const v = parseFloat(posQtyOverrides[id]); - if (!isNaN(v)) acc[id] = v; + const v = parseNumberInput(posQtyOverrides[id]); + if (v !== undefined) acc[id] = v; return acc; }, {}), step3DeliveryKm: deliveryKm, diff --git a/src/webparts/ctFormsPortal/forms/ct/components/utils/parseNumberInput.test.ts b/src/webparts/ctFormsPortal/forms/ct/components/utils/parseNumberInput.test.ts new file mode 100644 index 0000000..8ed6f88 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/utils/parseNumberInput.test.ts @@ -0,0 +1,52 @@ +import { parseNumberInput } from './parseNumberInput'; + +describe('parseNumberInput', () => { + // Претензия ОМТС от 2026-10-23, п.6: цену скопировали из КП «100 000» — сумма посчиталась от 100. + it('пробелы между разрядами не обрывают число', () => { + expect(parseNumberInput('100 000')).toBe(100000); + expect(parseNumberInput('1 00000')).toBe(100000); + expect(parseNumberInput('10 0 000')).toBe(100000); + }); + + // Word, Excel и PDF ставят между разрядами неразрывный пробел, а не обычный. + it('неразрывные пробелы — тоже', () => { + expect(parseNumberInput('100 000')).toBe(100000); + expect(parseNumberInput('1 234 567,5')).toBe(1234567.5); + }); + + it('запятая — десятичный разделитель, как в русских КП', () => { + expect(parseNumberInput('275 293,50')).toBe(275293.5); + expect(parseNumberInput('0,061')).toBe(0.061); + }); + + it('точка — тоже десятичный разделитель', () => { + expect(parseNumberInput('286575.5')).toBe(286575.5); + }); + + // КП на английском: «1,234,567.89». Есть и запятая, и точка — десятичный разделитель последний. + it('английский формат с разделителями разрядов', () => { + expect(parseNumberInput('1,234,567.89')).toBe(1234567.89); + expect(parseNumberInput('1.234.567,89')).toBe(1234567.89); + }); + + it('несколько одинаковых разделителей — это разряды', () => { + expect(parseNumberInput('1,000,000')).toBe(1000000); + expect(parseNumberInput('1.000.000')).toBe(1000000); + }); + + it('хвост из валюты или единиц не мешает', () => { + expect(parseNumberInput('100 000 ₸')).toBe(100000); + expect(parseNumberInput('1 540 USD')).toBe(1540); + }); + + it('пусто или не число — undefined, а не 0: «не введено» ≠ «ноль»', () => { + expect(parseNumberInput('')).toBeUndefined(); + expect(parseNumberInput(' ')).toBeUndefined(); + expect(parseNumberInput('abc')).toBeUndefined(); + }); + + it('обычные числа как раньше', () => { + expect(parseNumberInput('635')).toBe(635); + expect(parseNumberInput('-5')).toBe(-5); + }); +}); diff --git a/src/webparts/ctFormsPortal/forms/ct/components/utils/parseNumberInput.ts b/src/webparts/ctFormsPortal/forms/ct/components/utils/parseNumberInput.ts new file mode 100644 index 0000000..5627d39 --- /dev/null +++ b/src/webparts/ctFormsPortal/forms/ct/components/utils/parseNumberInput.ts @@ -0,0 +1,32 @@ +// Число из поля ввода. Цены и количества копируют из КП, Excel и PDF: «100 000», «275 293,50», +// «1,234,567.89», с неразрывными пробелами между разрядами. parseFloat читает до первого пробела — +// «100 000» превращалось в 100, и сумма считалась в тысячу раз меньше. +export function parseNumberInput(value: string): number | undefined { + // \s в JS покрывает и неразрывные пробелы (U+00A0, U+202F). + const compact = value.replace(/\s/g, ''); + if (compact === '') return undefined; + + const parsed = parseFloat(normalizeSeparators(compact)); + return isNaN(parsed) ? undefined : parsed; +} + +// Приводит разделители к виду «1234567.89»: +// - есть и «,», и «.» — десятичный тот, что последний, другой — разряды («1,234.5», «1.234,5»); +// - один и тот же знак несколько раз — разряды («1,000,000»); +// - один знак один раз — десятичный (в русских КП это запятая: «0,061»). +function normalizeSeparators(text: string): string { + const lastComma = text.lastIndexOf(','); + const lastDot = text.lastIndexOf('.'); + + if (lastComma !== -1 && lastDot !== -1) { + const decimal = lastComma > lastDot ? ',' : '.'; + const thousands = decimal === ',' ? '.' : ','; + return text.split(thousands).join('').replace(decimal, '.'); + } + + const separator = lastComma !== -1 ? ',' : lastDot !== -1 ? '.' : undefined; + if (separator === undefined) return text; + + const occurrences = text.split(separator).length - 1; + return occurrences > 1 ? text.split(separator).join('') : text.replace(separator, '.'); +}