- Копейки, процент и порог 5 % вынесены из mockCalc в utils/money; editorModel больше не зависит от фейка, тесты перенесены. - Один разбор суммы строки ввода для проверки, живого контроля и сравнения; некорректные строки в живой контроль не входят. - isDirty сравнивает суммы копейками; комментарий — точно. - Черновик: savedAt проверяется через Date.parse. - Тесты границ порога, пустых сторон и неизвестного rule_id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
144 lines
6.2 KiB
TypeScript
144 lines
6.2 KiB
TypeScript
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));
|
||
}
|