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 { describe, expect, it } from 'vitest';
|
||||||
import { formatAmount, parseAmountInput, sumAmounts } from './money';
|
import { formatAmount, formatScaledAmount, parseAmountInput, sumAmounts } from './money';
|
||||||
|
|
||||||
const NBSP = ' ';
|
const NBSP = ' ';
|
||||||
|
|
||||||
@ -75,3 +75,43 @@ describe('sumAmounts', () => {
|
|||||||
expect(sumAmounts(['1.234'])).toBeNull();
|
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}`;
|
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. */
|
/** Ввод аналитика → строка суммы для API; пустое — null; неверный формат — ok: false. */
|
||||||
export function parseAmountInput(input: string): { ok: true; value: string | null } | { ok: false } {
|
export function parseAmountInput(input: string): { ok: true; value: string | null } | { ok: false } {
|
||||||
const normalized = input.replace(/[\s ]/g, '').replace(',', '.');
|
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 }[] = [
|
/** Единицы показа сумм сводной ОСВ и степень десяти (суммы API — в рублях). */
|
||||||
{ value: 'руб.', label: 'руб.' },
|
export const UNIT_SCALE: Record<Unit, number> = {
|
||||||
{ value: 'тыс. руб.', label: 'тыс. руб.' },
|
'руб.': 0,
|
||||||
{ value: 'млн. руб.', label: 'млн. руб.' },
|
'тыс. руб.': 3,
|
||||||
{ value: 'млрд. руб.', label: 'млрд. руб.' },
|
'млн руб.': 6,
|
||||||
];
|
'млрд руб.': 9,
|
||||||
|
|
||||||
const DIVISORS: Record<Unit, number> = {
|
|
||||||
'руб.': 1,
|
|
||||||
'тыс. руб.': 1e3,
|
|
||||||
'млн. руб.': 1e6,
|
|
||||||
'млрд. руб.': 1e9,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createFormatter(precision: number): Intl.NumberFormat {
|
export const UNITS: { value: Unit; label: string }[] = (Object.keys(UNIT_SCALE) as Unit[]).map((u) => ({ value: u, label: u }));
|
||||||
return new Intl.NumberFormat('ru-RU', {
|
|
||||||
minimumFractionDigits: precision,
|
|
||||||
maximumFractionDigits: precision,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatNumeric(raw: string | null, unit: Unit, nf: Intl.NumberFormat): string {
|
export const PRECISIONS = [0, 1, 2, 3] as const;
|
||||||
if (raw == null || raw.trim() === '') return '—';
|
|
||||||
const n = Number(raw);
|
|
||||||
if (!Number.isFinite(n)) return raw;
|
|
||||||
return nf.format(n / DIVISORS[unit]);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** «Единицы» и «Знаков после запятой» сводной ОСВ [30]: только показ, суммы считаются на строках (`utils/money.ts`). */
|
||||||
export function useNumberDisplay(initial?: { unit?: Unit; precision?: number }) {
|
export function useNumberDisplay(initial?: { unit?: Unit; precision?: number }) {
|
||||||
const [unit, setUnit] = useState<Unit>(initial?.unit ?? 'руб.');
|
const [unit, setUnit] = useState<Unit>(initial?.unit ?? 'руб.');
|
||||||
const [precision, setPrecision] = useState(initial?.precision ?? 2);
|
const [precision, setPrecision] = useState(initial?.precision ?? 2);
|
||||||
|
|
||||||
const nf = useMemo(() => createFormatter(precision), [precision]);
|
const format = useCallback(
|
||||||
|
(raw: string | null) => formatScaledAmount(raw, { scale: UNIT_SCALE[unit], precision, zeroDash: true }),
|
||||||
const format = useCallback((raw: string | null) => formatNumeric(raw, unit, nf), [unit, nf]);
|
[unit, precision],
|
||||||
|
);
|
||||||
|
|
||||||
return { unit, setUnit, precision, setPrecision, format };
|
return { unit, setUnit, precision, setPrecision, format };
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user