fix(web): «Единицы» сводной ОСВ — масштаб на строках без float, ноль — «—» (AURORA-1589, PR #166)

formatScaledAmount в utils/money.ts: запятая сдвигается на 3/6/9 разрядов в строке,
округление половины от нуля на BigInt; нулевая сумма — «—» (zeroDash, как до 71ef633).
Подписи «млн руб.», «млрд руб.» без точки. Исправляет коммит 71ef633.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-23 01:15:55 +04:00
parent 46d553b074
commit 54de39727b
3 changed files with 105 additions and 30 deletions

View File

@ -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');
});
});

View File

@ -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(',', '.');

View File

@ -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<Unit, number> = {
'руб.': 1,
'тыс. руб.': 1e3,
'млн. руб.': 1e6,
'млрд. руб.': 1e9,
/** Единицы показа сумм сводной ОСВ и степень десяти (суммы API — в рублях). */
export const UNIT_SCALE: Record<Unit, number> = {
'руб.': 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<Unit>(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 };
}