arm-core/web/src/utils/money.ts
Andrey Epifancev ec6ece127f fix(web): правки по ревью Task 3 — утилиты денег, разбор сумм, черновик (#206)
- Копейки, процент и порог 5 % вынесены из mockCalc в utils/money;
  editorModel больше не зависит от фейка, тесты перенесены.
- Один разбор суммы строки ввода для проверки, живого контроля и сравнения;
  некорректные строки в живой контроль не входят.
- isDirty сравнивает суммы копейками; комментарий — точно.
- Черновик: savedAt проверяется через Date.parse.
- Тесты границ порога, пустых сторон и неизвестного rule_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:12:53 +04:00

144 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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