/** Чистые функции вкладки «Гудвилл» (DOC-259025 §4–§6): ввод доли, предпросмотр P/G, фильтр знака, изменённые строки. */ import type { GoodwillEdit, GoodwillRow, GoodwillRowKeyIn, GoodwillSign } from '../types/goodwill/goodwill'; import { fromCents, toCents } from './money'; const SHARE = /^\d{1,3}(?:\.\d{1,2})?$/; export function rowKey(row: Pick): string { return `${row.head_legal_entity.id}:${row.subsidiary_legal_entity.id}`; } /** Доля 0–100, не больше двух знаков, запятая или точка; пусто — null. Лишние знаки — ошибка, не обрезка. */ export function parseShare(input: string): { ok: true; value: string | null } | { ok: false; error: string } { const value = input.trim().replace(',', '.'); if (value === '') return { ok: true, value: null }; if (!SHARE.test(value) || shareHundredths(value) > 10000n) { return { ok: false, error: 'Число от 0 до 100, не больше двух знаков после запятой' }; } return { ok: true, value }; } /** Доля строкой («80.5») → сотые доли процента (8050n). */ function shareHundredths(value: string): bigint { const [int, frac = ''] = value.split('.'); return BigInt(int) * 100n + BigInt(frac.padEnd(2, '0')); } /** Предварительные P = round(УК × d / 100, 2) и G = ФВ − P в копейках, половина вверх; как на бэке. */ export function previewAmounts( row: GoodwillRow, share: string | null, include: boolean, ): { participation: string | null; goodwill: string | null } { const fv = row.financial_investment_amount; const capital = row.charter_capital_amount; if (!row.has_investments || !include || share === null || fv === null || capital === null) { return { participation: null, goodwill: null }; } const parsed = parseShare(share); if (!parsed.ok || parsed.value === null) return { participation: null, goodwill: null }; const product = toCents(capital) * shareHundredths(parsed.value); const divisor = 10000n; const negative = product < 0n; const abs = negative ? -product : product; const rounded = (abs + divisor / 2n) / divisor; const p = negative ? -rounded : rounded; return { participation: fromCents(p), goodwill: fromCents(toCents(fv) - p) }; } /** Фильтр «Гудвилл»: нерассчитанный (null) — только во «Все значения». */ export function matchesSign(goodwill: string | null, sign: GoodwillSign): boolean { if (sign === 'all') return true; if (goodwill === null) return false; const cents = toCents(goodwill); if (sign === 'positive') return cents > 0n; if (sign === 'negative') return cents < 0n; return cents === 0n; } /** Значения строки с учётом правки: что уйдёт в пакет. */ export function effectiveRow(row: GoodwillRow, edit: GoodwillEdit | undefined): GoodwillRowKeyIn { const include = edit?.include_in_kfo ?? row.include_in_kfo; const share = edit && 'ownership_share' in edit ? (edit.ownership_share ?? null) : row.ownership_share; const parsed = share === null ? null : parseShare(share); const comment = edit && 'comment' in edit ? (edit.comment ?? null) : row.comment; let ownership = parsed?.ok ? parsed.value : share; // у неотмеченной строки поле доли скрыто: неверный ввод не исправить, поэтому уходит сохранённая доля if (!include && parsed && !parsed.ok) ownership = row.ownership_share; return { head_legal_entity_id: row.head_legal_entity.id, subsidiary_legal_entity_id: row.subsidiary_legal_entity.id, ownership_share: ownership, include_in_kfo: include, comment: comment?.trim() ? comment : null, }; } function sameShare(a: string | null, b: string | null): boolean { if (a === null || b === null) return a === b; if (!SHARE.test(a) || !SHARE.test(b)) return a === b; return shareHundredths(a) === shareHundredths(b); } /** Строки пакета — только те, где правка отличается от сохранённого. */ export function changedRows(rows: GoodwillRow[], edits: Record): GoodwillRowKeyIn[] { return rows.flatMap((row) => { const edit = edits[rowKey(row)]; if (!edit) return []; const next = effectiveRow(row, edit); const same = next.include_in_kfo === row.include_in_kfo && sameShare(next.ownership_share, row.ownership_share) && (next.comment?.trim() ?? null) === (row.comment?.trim() || null); return same ? [] : [next]; }); }