import type { EntryOut } from '../../types/api'; import { sumAmounts } from '../../utils/money'; export const AMOUNT_KEYS = [ 'opening_debit', 'opening_credit', 'turnover_debit', 'turnover_credit', 'closing_debit', 'closing_credit', ] as const; export type AmountKey = (typeof AMOUNT_KEYS)[number]; export type Amounts = Record; /** Строка таблицы «Просмотр ОСВ» в порядке файла: счёт, субсчёт, подразделение или аналитика (контрагент, объект ОС). */ export type OsvRow = Amounts & { key: string; account: string; department: string | null; analytics: string | null; inn: string | null; /** Глубина вложенности (0 — корень) — отступ в колонке аналитики, как в 1С. */ depth: number; /** У строки есть вложенные строки (счёт, субсчёт, подразделение с аналитикой) — выводится жирным. */ isGroup: boolean; }; export type OsvTable = { rows: OsvRow[]; /** Сумма корневых строк счёта — «Итого» файла 1С (не сумма строк: у 1С сальдо по аналитике развёрнутое). */ total: Amounts; /** Счета и субсчета строк для фильтра, в натуральном порядке. */ accounts: string[]; /** В файле есть уровень «Подразделение» — показать колонку. */ hasDepartments: boolean; /** Заголовок колонки аналитики по виду строк: «Контрагент», название вида из файла («Основные средства») или «Аналитика». */ analyticsTitle: string; /** Перечень видов аналитики для подсказки у заголовка — только когда заголовок «Аналитика» из-за смеси видов. */ analyticsHint: string | null; /** Среди строк есть контрагенты (COUNTERPARTY): только тогда нужны колонка ИНН и поиск по ИНН. */ hasCounterparties: boolean; }; const MIXED_TITLE = 'Аналитика'; /** Заголовок колонки аналитики по видам строк-листьев. Вид — COUNTERPARTY или название группировки из шапки файла * (`group_name`, у остальных типов — OTHER). Один вид: контрагенты → «Контрагент», иначе его название; смесь или * вид без названия → «Аналитика», а при смеси перечень видов уходит в подсказку. */ export function analyticsHeading(leaves: EntryOut[]): Pick { const kinds = new Map(); for (const leaf of leaves) { const name = leaf.group_name?.trim() || null; const key = leaf.group_type === 'COUNTERPARTY' ? 'COUNTERPARTY' : `name:${name ?? ''}`; const label = leaf.group_type === 'COUNTERPARTY' ? (name ?? 'Контрагенты') : name; if (!kinds.has(key) || kinds.get(key) === null) kinds.set(key, label); } const hasCounterparties = kinds.has('COUNTERPARTY'); if (kinds.size === 1) { const [[key, label]] = [...kinds]; if (key === 'COUNTERPARTY') return { analyticsTitle: 'Контрагент', analyticsHint: null, hasCounterparties }; return { analyticsTitle: label ?? MIXED_TITLE, analyticsHint: null, hasCounterparties }; } const labels = [...kinds.values()].map((label) => label ?? 'без названия'); return { analyticsTitle: MIXED_TITLE, analyticsHint: labels.length > 0 ? `Виды аналитики: ${labels.join(', ')}` : null, hasCounterparties, }; } const STRUCTURE = new Set(['ACCOUNT', 'DEPARTMENT']); /** Виды сумм в рублях: такие строки — обычные строки таблицы, не показатели. */ const RUBLE_TYPES = new Set(['BU', 'SUM']); const hasAmount = (entry: EntryOut) => AMOUNT_KEYS.some((key) => entry[key] !== null && entry[key] !== undefined); /** Строка-показатель (§1.3 правил предметной области: «Кол.», «НУ», «Вал.», «ПР»/«ВР» колонки «Показатели»): не уровень * группировки, а вид сумм родителя — OTHER, вид не рублёвый и не совпадает с видом родителя. Совпадение с родителем — * документ целиком в одном виде (НУ из шапки) или старый документ без вида строк. */ const isIndicator = (entry: EntryOut, parent: EntryOut | undefined) => parent !== undefined && entry.group_type === 'OTHER' && !RUBLE_TYPES.has(entry.data_type ?? '') && entry.data_type !== parent.data_type; /** Строки ОСВ (порядок API — по row_number) → таблица в порядке файла, как в 1С: счёт и субсчета — строки-группы над * вложенными строками. Договоры (CONTRACT) отдельными строками не идут: их сумма уже в строке контрагента, как в сводной * ОСВ (§3). Строки-показатели (`isIndicator`) в рублёвую таблицу не попадают и родителя группой не делают. Субсчёт или * подразделение без вложенных строк выводится, только если у него есть суммы. Битые ссылки на родителя и циклы не * роняют разбор: предки ищутся с ограничением шагов. */ export function flattenEntries(entries: EntryOut[]): OsvTable { const byId = new Map(entries.map((entry) => [entry.id, entry])); const parentOf = (entry: EntryOut) => (entry.parent_entry_id ? byId.get(entry.parent_entry_id) : undefined); const shown = entries.filter((entry) => entry.group_type !== 'CONTRACT' && !isIndicator(entry, parentOf(entry))); const withChildren = new Set(shown.map((entry) => entry.parent_entry_id).filter((id): id is string => id !== null)); const ancestors = (entry: EntryOut): EntryOut[] => { const chain: EntryOut[] = []; let current = parentOf(entry); for (let steps = 0; current && current.id !== entry.id && steps < entries.length; steps++) { chain.push(current); current = parentOf(current); } return chain; }; const rows: OsvRow[] = []; const leaves: EntryOut[] = []; for (const entry of shown) { const isGroup = withChildren.has(entry.id); const structural = STRUCTURE.has(entry.group_type ?? ''); if (structural && !isGroup && !hasAmount(entry)) continue; if (!structural && !isGroup) leaves.push(entry); const parents = ancestors(entry); const chain = [entry, ...parents]; const department = chain.find((item) => item.group_type === 'DEPARTMENT'); rows.push({ key: entry.id, account: chain.find((item) => item.account_code)?.account_code ?? '', department: department?.row_value ?? null, analytics: entry.group_type === 'DEPARTMENT' ? null : (entry.row_value ?? entry.counterparty_name), inn: entry.counterparty_inn, depth: parents.length, isGroup, ...(Object.fromEntries(AMOUNT_KEYS.map((key) => [key, entry[key]])) as Amounts), }); } const roots = entries.filter((entry) => !entry.parent_entry_id || !byId.has(entry.parent_entry_id)); const total = Object.fromEntries(AMOUNT_KEYS.map((key) => [key, sumAmounts(roots.map((root) => root[key]))])) as Amounts; const accounts = [...new Set(rows.map((row) => row.account).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'ru', { numeric: true })); return { rows, total, accounts, hasDepartments: entries.some((entry) => entry.group_type === 'DEPARTMENT'), ...analyticsHeading(leaves) }; } /** Фильтр по счёту — счёт и все его субсчета («62» → 62, 62.01, 62.02); поиск по аналитике (и ИНН) без учёта регистра. * При поиске строки-группы скрываются: остаются только найденные строки. На клиенте: строки документа загружены целиком. */ export function filterRows(rows: OsvRow[], query: string, account: string | undefined): OsvRow[] { const q = query.trim().toLowerCase(); return rows.filter( (row) => (!account || row.account === account || row.account.startsWith(`${account}.`)) && (!q || (!row.isGroup && ((row.analytics ?? '').toLowerCase().includes(q) || (row.inn ?? '').includes(q)))), ); }