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:
parent
46d553b074
commit
54de39727b
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(',', '.');
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user