fix(ct): цена с пробелами между разрядами считается целиком (#6)

Ввод чисел разбирался parseFloat, который читает до первого пробела: цену
«100 000», скопированную из КП, таблица считала как 100. Все поля ввода СТ
(цена, цена в ₸, количество, километраж, курс валюты) разбирает одна функция:
обычные и неразрывные пробелы, «275 293,50», «1,234,567.89», хвост «₸».

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Тимур Городецкий
2026-10-27 15:00:05 +05:00
parent 9b0135b379
commit 6ad40bc523
5 changed files with 100 additions and 13 deletions
@@ -2,6 +2,7 @@ import * as React from 'react';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { Button, Alert, TextInput, IconButton, Spinner } from '@tnsu/ui-kit'; import { Button, Alert, TextInput, IconButton, Spinner } from '@tnsu/ui-kit';
import { CurrencyRate } from '../types/domain'; import { CurrencyRate } from '../types/domain';
import { parseNumberInput } from '../utils/parseNumberInput';
import styles from './CurrencyRatesForm.module.scss'; import styles from './CurrencyRatesForm.module.scss';
interface Props { interface Props {
@@ -58,9 +59,9 @@ export const CurrencyRatesForm: React.FC<Props> = ({ rates, onChange, readOnly =
const updateRate = useCallback( const updateRate = useCallback(
(code: string, value: string) => { (code: string, value: string) => {
const parsed = parseFloat(value.replace(',', '.')); const parsed = parseNumberInput(value);
onChange( 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] [rates, onChange]
@@ -4,6 +4,7 @@ import { CurrencyRate } from '../types/domain';
import { PositionRow } from './matrixTypes'; import { PositionRow } from './matrixTypes';
import { Step3Matrix } from './useStep3Matrix'; import { Step3Matrix } from './useStep3Matrix';
import { getUnitStatus } from '../utils/unitConversion'; import { getUnitStatus } from '../utils/unitConversion';
import { parseNumberInput } from '../utils/parseNumberInput';
import { unitStatusClass } from './unitStatusStyles'; import { unitStatusClass } from './unitStatusStyles';
import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers'; import { getSubCols, formatKzt, formatNum, formatQty, round2Str, buildOfferKey, isMinPositionTotal } from './matrixHelpers';
import styles from './Step3ComparisonTable.module.scss'; import styles from './Step3ComparisonTable.module.scss';
@@ -55,7 +56,7 @@ export function MatrixRowView({ pos, posIndex, matrix, currencyRates, isRestrict
/> />
</div> </div>
) : ( ) : (
posQtyOverrides[pos.id] !== undefined ? parseFloat(posQtyOverrides[pos.id]) : pos.quantity (posQtyOverrides[pos.id] !== undefined ? parseNumberInput(posQtyOverrides[pos.id]) : undefined) ?? pos.quantity
)} )}
</td> </td>
</> </>
@@ -6,6 +6,7 @@ import { CTFormData } from '../hooks/useCtForm';
import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes'; import { ExtraSupplier, ExtraPosition, PositionRow, CellEdit, EditableOffers, CriteriaEntry } from './matrixTypes';
import { applyCreatedIds, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers'; import { applyCreatedIds, buildOfferKey, computeColumnsOffers, sumPositionTotals } from './matrixHelpers';
import { buildExcludedRecordsForSave } from '../utils/excludedColumns'; import { buildExcludedRecordsForSave } from '../utils/excludedColumns';
import { parseNumberInput } from '../utils/parseNumberInput';
import { IVendorRecord } from '../../services/VendorDatabaseService'; import { IVendorRecord } from '../../services/VendorDatabaseService';
import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns'; import { buildColumnDisplayNames, buildColumnLabels, findColumnForRecord } from '../utils/supplierColumns';
import { getNoOfferSuppliers } from '../utils/noOfferSuppliers'; import { getNoOfferSuppliers } from '../utils/noOfferSuppliers';
@@ -275,8 +276,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
}); });
const qtyOverrides: Record<string, number> = {}; const qtyOverrides: Record<string, number> = {};
Object.keys(posQtyOverridesRef.current).forEach(id => { Object.keys(posQtyOverridesRef.current).forEach(id => {
const v = parseFloat(posQtyOverridesRef.current[id]); const v = parseNumberInput(posQtyOverridesRef.current[id]);
if (!isNaN(v)) qtyOverrides[id] = v; if (v !== undefined) qtyOverrides[id] = v;
}); });
onSaveRef.current({ onSaveRef.current({
offers: updatedOffers, offers: updatedOffers,
@@ -399,8 +400,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
setEdits(prev => { setEdits(prev => {
const pos = allPositionsRef.current.find(p => p.id === positionId); 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 items = prev[key] ?? [{ supplierId, positionId, itemIndex: rowIdx, quantity: pos?.quantity || undefined, unit: pos?.unit, supplierProductName: pos?.materialName }];
const parsed = parseFloat(value.replace(',', '.')); const parsed = parseNumberInput(value);
const numVal = isNaN(parsed) ? 0 : Math.round(parsed * 1000) / 1000; const numVal = parsed === undefined ? 0 : Math.round(parsed * 1000) / 1000;
return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, [field]: numVal } : it) }; return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, [field]: numVal } : it) };
}); });
}, },
@@ -416,8 +417,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
setEdits(prev => { setEdits(prev => {
const pos = allPositionsRef.current.find(p => p.id === positionId); 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 items = prev[key] ?? [{ supplierId, positionId, itemIndex: rowIdx, quantity: pos?.quantity || undefined, unit: pos?.unit, supplierProductName: pos?.materialName }];
const parsed = parseFloat(value.replace(',', '.')); const parsed = parseNumberInput(value);
const override = isNaN(parsed) ? undefined : Math.round(parsed * 1000) / 1000; const override = parsed === undefined ? undefined : Math.round(parsed * 1000) / 1000;
return { ...prev, [key]: items.map((it, i) => i === rowIdx ? { ...it, priceKztOverride: override } : it) }; 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 handleDeliveryKmChange = (posId: string, supId: string, val: string): void => {
const km = parseFloat(val); const km = parseNumberInput(val);
const key = `${posId}::${supId}`; const key = `${posId}::${supId}`;
const updated = isNaN(km) const updated = km === undefined
? (({ [key]: _, ...rest }) => rest)(deliveryKm) ? (({ [key]: _, ...rest }) => rest)(deliveryKm)
: { ...deliveryKm, [key]: km }; : { ...deliveryKm, [key]: km };
setDeliveryKm(updated); setDeliveryKm(updated);
@@ -894,8 +895,8 @@ export function useStep3Matrix(args: UseStep3MatrixArgs) {
spId: extraPosSpIds[ep.id] ?? ep.spId, spId: extraPosSpIds[ep.id] ?? ep.spId,
})), })),
step3PositionQtyOverrides: Object.keys(posQtyOverrides).reduce<Record<string, number>>((acc, id) => { step3PositionQtyOverrides: Object.keys(posQtyOverrides).reduce<Record<string, number>>((acc, id) => {
const v = parseFloat(posQtyOverrides[id]); const v = parseNumberInput(posQtyOverrides[id]);
if (!isNaN(v)) acc[id] = v; if (v !== undefined) acc[id] = v;
return acc; return acc;
}, {}), }, {}),
step3DeliveryKm: deliveryKm, step3DeliveryKm: deliveryKm,
@@ -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);
});
});
@@ -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, '.');
}