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:
+3
-2
@@ -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]
|
||||||
|
|||||||
+2
-1
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
+11
-10
@@ -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, '.');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user