arm-web/src/utils/money.ts
Andrey Epifancev c3fcac2efd feat(osv): «Просмотр ОСВ» — плоская таблица вместо дерева (#12)
- Строка = аналитика нижнего уровня (контрагент, объект ОС); строки счетов и
  субсчетов не показываются, договоры (CONTRACT) — в строке контрагента, как в
  сводной ОСВ (§3); субсчёт/подразделение без нижних строк с суммами — строкой с
  пустой аналитикой.
- Колонки: Субсчёт · Подразделение (если уровень есть в файле) · Контрагент /
  аналитика · ИНН · 6 сумм; закреплённая строка «Итого по документу» = сумма
  корневых строк счёта (= «Итого» файла 1С).
- Поиск по контрагенту/ИНН, фильтр по субсчёту, «Строк: N» — на клиенте.
- flattenEntries (osvRows.ts) и точное sumAmounts (копейками, BigInt) с тестами;
  EntryOut.group_type — как в схеме arm-core; entryTree удалён.
- Пустой документ: «В документе нет строк» вместо «No data».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:22:16 +04:00

69 lines
2.9 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}`;
}
/** Ввод аналитика → строка суммы для 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: ReadonlyArray<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')}`;
}