From 54de39727be6a893434ba6c58e067d147e3e56aa Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 23 Oct 2026 01:15:55 +0400 Subject: [PATCH] =?UTF-8?q?fix(web):=20=C2=AB=D0=95=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D0=B8=D1=86=D1=8B=C2=BB=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BD=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=9E=D0=A1=D0=92=20=E2=80=94=20=D0=BC=D0=B0=D1=81?= =?UTF-8?q?=D1=88=D1=82=D0=B0=D0=B1=20=D0=BD=D0=B0=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D1=85=20=D0=B1=D0=B5=D0=B7=20float,=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=20=E2=80=94=20=C2=AB=E2=80=94=C2=BB=20(AUROR?= =?UTF-8?q?A-1589,=20PR=20#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatScaledAmount в utils/money.ts: запятая сдвигается на 3/6/9 разрядов в строке, округление половины от нуля на BigInt; нулевая сумма — «—» (zeroDash, как до 71ef633). Подписи «млн руб.», «млрд руб.» без точки. Исправляет коммит 71ef633. Co-Authored-By: Claude Opus 5.5 --- web/src/utils/money.test.ts | 42 +++++++++++++++- web/src/utils/money.ts | 48 +++++++++++++++++++ .../ConsolidatedOsv/useNumberDisplay.tsx | 45 +++++++---------- 3 files changed, 105 insertions(+), 30 deletions(-) diff --git a/web/src/utils/money.test.ts b/web/src/utils/money.test.ts index 274647c..88a6ff0 100644 --- a/web/src/utils/money.test.ts +++ b/web/src/utils/money.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatAmount, parseAmountInput, sumAmounts } from './money'; +import { formatAmount, formatScaledAmount, parseAmountInput, sumAmounts } from './money'; const NBSP = ' '; @@ -75,3 +75,43 @@ describe('sumAmounts', () => { expect(sumAmounts(['1.234'])).toBeNull(); }); }); + +describe('formatScaledAmount', () => { + const rub = (precision: number) => ({ scale: 0, precision, zeroDash: true }); + const thousands = (precision: number) => ({ scale: 3, precision, zeroDash: true }); + it('рубли с двумя знаками — как formatAmount', () => { + expect(formatScaledAmount('4454151.93', rub(2))).toBe(`4${NBSP}454${NBSP}151,93`); + expect(formatScaledAmount('-8213.3', rub(2))).toBe(`-8${NBSP}213,30`); + }); + it('сдвиг запятой без float', () => { + expect(formatScaledAmount('4454151.93', thousands(2))).toBe(`4${NBSP}454,15`); + expect(formatScaledAmount('128058143.65', { scale: 6, precision: 3 })).toBe('128,058'); + expect(formatScaledAmount('2555438965.10', { scale: 9, precision: 2 })).toBe('2,56'); + // float: 9007199254740993.01 / 1000 теряет разряды, строка — нет + expect(formatScaledAmount('9007199254740993.01', thousands(2))).toBe(`9${NBSP}007${NBSP}199${NBSP}254${NBSP}740,99`); + }); + it('округление — половина от нуля', () => { + expect(formatScaledAmount('1234.5', thousands(0))).toBe('1'); + expect(formatScaledAmount('1500', thousands(0))).toBe('2'); + expect(formatScaledAmount('-1500', thousands(0))).toBe('-2'); + expect(formatScaledAmount('0.05', rub(1))).toBe('0,1'); + expect(formatScaledAmount('2.345', rub(2))).toBe('2,35'); + }); + it('больше знаков, чем в сумме, — дополняется нулями', () => { + expect(formatScaledAmount('12.5', rub(3))).toBe('12,500'); + expect(formatScaledAmount('100', rub(0))).toBe('100'); + }); + it('ноль — прочерк по исходной сумме; округлённый до нуля — без минуса', () => { + expect(formatScaledAmount('0.00', thousands(2))).toBe('—'); + expect(formatScaledAmount('0', rub(2))).toBe('—'); + expect(formatScaledAmount('0.00', { scale: 0, precision: 2 })).toBe('0,00'); + expect(formatScaledAmount('0.40', { scale: 6, precision: 2, zeroDash: true })).toBe('0,00'); + expect(formatScaledAmount('-0.40', { scale: 6, precision: 2, zeroDash: true })).toBe('0,00'); + }); + it('пустое — прочерк, нераспознанное — как есть', () => { + expect(formatScaledAmount(null, rub(2))).toBe('—'); + expect(formatScaledAmount('', rub(2))).toBe('—'); + expect(formatScaledAmount('abc', rub(2))).toBe('abc'); + expect(formatScaledAmount('1e5', rub(2))).toBe('1e5'); + }); +}); diff --git a/web/src/utils/money.ts b/web/src/utils/money.ts index 9d4150e..f1e6082 100644 --- a/web/src/utils/money.ts +++ b/web/src/utils/money.ts @@ -38,6 +38,54 @@ export function formatAmount(value: string | null | undefined, format: AmountFor return `${isZero ? '' : sign}${grouped},${cents}`; } +export type ScaledFormat = { + /** Степень десяти единиц показа: 0 — руб., 3 — тыс. руб., 6 — млн руб., 9 — млрд руб. */ + scale: number; + /** Знаков после запятой (0…3). */ + precision: number; + /** Ноль показывать прочерком (по исходной сумме: 0,40 руб. в млн — «0,00», а не «—»). */ + zeroDash?: boolean; +}; + +/** + * Сумма из API (строка Decimal) в единицах 10^scale с `precision` знаками → «1 234,57». Считается на строке и BigInt: + * запятая сдвигается на `scale` разрядов, округление — половина от нуля; float не используется. Пустое — «—», + * нераспознанное — как есть. + */ +export function formatScaledAmount(value: string | null | undefined, format: ScaledFormat): string { + if (value === null || value === undefined || value === '') { + return '—'; + } + const match = PLAIN.exec(value); + if (!match) { + return value; + } + const [, sign, intPart, frac = ''] = match; + const isZero = /^0+$/.test(intPart) && /^0*$/.test(frac); + if (isZero && format.zeroDash) { + return '—'; + } + const precision = Math.max(0, Math.trunc(format.precision)); + // число = digits / 10^places + const digits = BigInt(intPart + frac); + const places = frac.length + Math.max(0, Math.trunc(format.scale)); + let scaled: bigint; + if (places > precision) { + const divisor = 10n ** BigInt(places - precision); + scaled = digits / divisor; + if ((digits % divisor) * 2n >= divisor) scaled += 1n; + } else { + scaled = digits * 10n ** BigInt(precision - places); + } + const unit = 10n ** BigInt(precision); + const grouped = (scaled / unit).toString().replace(/\B(?=(\d{3})+(?!\d))/g, NBSP); + const minus = sign && scaled !== 0n ? '-' : ''; + if (precision === 0) { + return `${minus}${grouped}`; + } + return `${minus}${grouped},${(scaled % unit).toString().padStart(precision, '0')}`; +} + /** Ввод аналитика → строка суммы для API; пустое — null; неверный формат — ok: false. */ export function parseAmountInput(input: string): { ok: true; value: string | null } | { ok: false } { const normalized = input.replace(/[\s ]/g, '').replace(',', '.'); diff --git a/web/src/widgets/ConsolidatedOsv/useNumberDisplay.tsx b/web/src/widgets/ConsolidatedOsv/useNumberDisplay.tsx index 80a1552..141dfd9 100644 --- a/web/src/widgets/ConsolidatedOsv/useNumberDisplay.tsx +++ b/web/src/widgets/ConsolidatedOsv/useNumberDisplay.tsx @@ -1,42 +1,29 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useCallback, useState } from 'react'; +import { formatScaledAmount } from '../../utils/money'; -export type Unit = 'руб.' | 'тыс. руб.' | 'млн. руб.' | 'млрд. руб.'; +export type Unit = 'руб.' | 'тыс. руб.' | 'млн руб.' | 'млрд руб.'; -export const UNITS: { value: Unit; label: string }[] = [ - { value: 'руб.', label: 'руб.' }, - { value: 'тыс. руб.', label: 'тыс. руб.' }, - { value: 'млн. руб.', label: 'млн. руб.' }, - { value: 'млрд. руб.', label: 'млрд. руб.' }, -]; - -const DIVISORS: Record = { - 'руб.': 1, - 'тыс. руб.': 1e3, - 'млн. руб.': 1e6, - 'млрд. руб.': 1e9, +/** Единицы показа сумм сводной ОСВ и степень десяти (суммы API — в рублях). */ +export const UNIT_SCALE: Record = { + 'руб.': 0, + 'тыс. руб.': 3, + 'млн руб.': 6, + 'млрд руб.': 9, }; -export function createFormatter(precision: number): Intl.NumberFormat { - return new Intl.NumberFormat('ru-RU', { - minimumFractionDigits: precision, - maximumFractionDigits: precision, - }); -} +export const UNITS: { value: Unit; label: string }[] = (Object.keys(UNIT_SCALE) as Unit[]).map((u) => ({ value: u, label: u })); -export function formatNumeric(raw: string | null, unit: Unit, nf: Intl.NumberFormat): string { - if (raw == null || raw.trim() === '') return '—'; - const n = Number(raw); - if (!Number.isFinite(n)) return raw; - return nf.format(n / DIVISORS[unit]); -} +export const PRECISIONS = [0, 1, 2, 3] as const; +/** «Единицы» и «Знаков после запятой» сводной ОСВ [30]: только показ, суммы считаются на строках (`utils/money.ts`). */ export function useNumberDisplay(initial?: { unit?: Unit; precision?: number }) { const [unit, setUnit] = useState(initial?.unit ?? 'руб.'); const [precision, setPrecision] = useState(initial?.precision ?? 2); - const nf = useMemo(() => createFormatter(precision), [precision]); - - const format = useCallback((raw: string | null) => formatNumeric(raw, unit, nf), [unit, nf]); + const format = useCallback( + (raw: string | null) => formatScaledAmount(raw, { scale: UNIT_SCALE[unit], precision, zeroDash: true }), + [unit, precision], + ); return { unit, setUnit, precision, setPrecision, format }; }