const NBSP = ' '; const PLAIN = /^(-?)(\d+)(?:\.(\d+))?$/; const INPUT = /^-?\d+(\.\d{1,2})?$/; const MONEY = /^(-?)(\d+)(?:\.(\d{1,2}))?$/; export type AmountFormat = { /** Ноль показывать прочерком (свод, ОСВ — как в макете [30]). */ zeroDash?: boolean; /** 'auto' — без копеек, если они нулевые (формы ББ/ОФР в тыс. руб., макет [41]). */ fraction?: 'always' | 'auto'; /** Что показать вместо пустого значения. */ empty?: string; }; /** Сумма из API (строка Decimal) → «1 234 567,80». Без арифметики над float; нераспознанное — как есть. */ export function formatAmount(value: string | null | undefined, format: AmountFormat = {}): string { const empty = format.empty ?? '—'; if (value === null || value === undefined || value === '') { return empty; } const match = PLAIN.exec(value); if (!match) { return value; } const [, sign, intPart, frac = ''] = match; if (frac.length > 2) { return value; } const isZero = /^0+$/.test(intPart) && /^0*$/.test(frac); if (isZero && format.zeroDash) { return '—'; } const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, NBSP); const cents = frac.padEnd(2, '0'); if (format.fraction === 'auto' && cents === '00') { return `${isZero ? '' : sign}${grouped}`; } 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(',', '.'); if (normalized === '') { return { ok: true, value: null }; } return INPUT.test(normalized) ? { ok: true, value: normalized } : { ok: false }; } /** Точная сумма строк Decimal из API (копейками, без float) → «123.45»; пустые пропускаются, все пустые — null. * Сумма, которую не удалось разобрать (больше двух знаков, текст), делает итог null — итог не выдумываем. */ export function sumAmounts(values: readonly (string | null | undefined)[]): string | null { let cents = 0n; let any = false; for (const value of values) { if (value === null || value === undefined || value === '') continue; const match = MONEY.exec(value); if (!match) return null; const [, sign, intPart, frac = ''] = match; const amount = BigInt(intPart) * 100n + BigInt(frac.padEnd(2, '0')); cents += sign ? -amount : amount; any = true; } if (!any) return null; const negative = cents < 0n; const abs = negative ? -cents : cents; return `${negative ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`; } /** Порог отклонения в сотых процента: 5 % = 500. Выше или равно — «красный» (см. контракт, `exceeds_threshold`). */ export const THRESHOLD_PERCENT_HUNDREDTHS = 500n; /** Строка суммы (`"-12.5"`, до двух знаков) → копейки BigInt. Не сумма — ошибка. */ export function toCents(value: string): bigint { const match = MONEY.exec(value); if (!match) throw new Error(`Не сумма: ${value}`); const cents = BigInt(match[2]) * 100n + BigInt((match[3] ?? '').padEnd(2, '0')); return match[1] ? -cents : cents; } /** Копейки BigInt → строка суммы с двумя знаками (`"-0.05"`). */ export function fromCents(cents: bigint): string { const abs = cents < 0n ? -cents : cents; return `${cents < 0n ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`; } /** Процент отклонения к меньшей сумме, два знака, половина вверх; `null`, если меньшая сумма не положительна. */ export function percentOfSmaller(a: string, b: string): string | null { const x = toCents(a); const y = toCents(b); const min = x < y ? x : y; if (min <= 0n) return null; const diff = x < y ? y - x : x - y; return fromCents((diff * 20000n + min) / (2n * min)); }