"""Расчёт шахматки по готовой сводной ОСВ и выбранным наборам счетов.""" import uuid from collections import defaultdict from collections.abc import Iterable, Sequence from dataclasses import dataclass from decimal import Decimal from typing import Literal from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from src.common.exceptions import DomainValidationError, NotFoundError from src.domain.consolidated_osv import ConsolidatedRow, Entity from src.domain.policy import VisibilityScope from src.models.accounting import ChartOfAccount, EliminationRule from src.models.consolidation import Consolidation, ConsolidationVersion from src.models.directories import CompanyGroup from src.models.enums import AccountKind, PeriodQuarter, StatementType from src.repositories import directories as directory_repo from src.repositories import elimination_rules as elimination_rules_repo from src.schemas.consolidations import ( IntercompanyMatrixCellOut, IntercompanyMatrixCompanyOut, IntercompanyMatrixOut, IntercompanyResultAmountOut, IntercompanyResultArticleOut, IntercompanyResultTableOut, UnpairedOsvOut, ) from src.services.consolidated_osv import ConsolidatedOsvService, row_out Measure = Literal["closing", "turnover"] Side = Literal["D", "K"] ZERO = Decimal("0") VARIANCE_THRESHOLD_PERCENT = Decimal("5") DEBIT_KINDS = frozenset({AccountKind.A, AccountKind.AP}) CREDIT_KINDS = frozenset({AccountKind.P, AccountKind.AP}) @dataclass(frozen=True) class ResultArticleSpec: code: str name: str side: Side account_codes: tuple[str, ...] excluded_codes: tuple[str, ...] = () RESULT_ARTICLES = ( ResultArticleSpec( "receivables", "Дебиторская задолженность", "D", ("60.02", "60.22", "60.32", "62.01", "62.03", "62.21", "62.31", "76"), ("76.07.5", "76.07.9"), ), ResultArticleSpec( "payables", "Кредиторская задолженность", "K", ("60.01", "60.03", "60.21", "60.31", "62.02", "62.22", "62.32", "76"), ("76.07.1",), ), ResultArticleSpec("financial_investments", "Финансовые вложения", "D", ("58.01", "58.03")), ResultArticleSpec("long_term_loans", "Долгосрочные кредиты и займы", "K", ("67", "67.03")), ResultArticleSpec("short_term_loans", "Краткосрочные кредиты и займы", "K", ("66", "66.03", "66.04")), ) @dataclass(frozen=True) class MatrixSelection: """Счета дебетовой и кредитовой сторон и показатель выбранного режима.""" company_1_account_ids: frozenset[uuid.UUID] company_2_account_ids: frozenset[uuid.UUID] measure: Measure @dataclass(frozen=True) class MatrixBuildResult: """Матрица и данные свода, использованные при её построении.""" matrix: IntercompanyMatrixOut version: ConsolidationVersion consolidation: Consolidation accounts: list[ChartOfAccount] selection: MatrixSelection rows: tuple[ConsolidatedRow, ...] rule: EliminationRule | None @dataclass(frozen=True) class MatrixExportInfo: group_name: str year: int quarter: PeriodQuarter version_no: int mode: Literal["ALL", "MANUAL", "PRESET"] debit_account_codes: tuple[str, ...] credit_account_codes: tuple[str, ...] measure: Measure = "closing" rule_id: uuid.UUID | None = None rule_no: int | None = None rule_version_no: int | None = None @property def filename(self) -> str: preset = f"_preset-{self.rule_id}" if self.rule_id is not None else "" return f"intercompany-matrix_{self.group_name}_{self.year}-{self.quarter.value}_v{self.version_no}{preset}.xlsx" def _get_amount(row: ConsolidatedRow, measure: Measure, side: Side) -> Decimal: if measure == "closing": value = row.closing_debit if side == "D" else row.closing_credit else: value = row.turnover_debit if side == "D" else row.turnover_credit return value if value is not None else ZERO def calculate_matrix( rows: Iterable[ConsolidatedRow], member_companies: Sequence[IntercompanyMatrixCompanyOut], selection: MatrixSelection, ) -> IntercompanyMatrixOut: """Суммы по парам компаний; Сторона 1: дебет ОСВ компании A по контрагенту Б. Сторона 2: кредит ОСВ компании Б по контрагенту A. """ if selection.measure not in ("closing", "turnover"): raise ValueError(f"неизвестный показатель правила: {selection.measure}") company_info = tuple({company.id: company for company in member_companies}.values()) company_ids = tuple(company.id for company in company_info) members = set(company_ids) amounts_1: dict[tuple[uuid.UUID, uuid.UUID], dict] = defaultdict(lambda: {"amount": ZERO, "entry_osv_ids": set()}) amounts_2: dict[tuple[uuid.UUID, uuid.UUID], dict] = defaultdict(lambda: {"amount": ZERO, "entry_osv_ids": set()}) skipped_entries: dict[tuple[uuid.UUID, uuid.UUID], int] = defaultdict(int) for row in rows: owner = row.legal_entity_id counterparty = row.counterparty_id if owner not in members or counterparty not in members or owner == counterparty: continue pair = (owner, counterparty) if row.account_id is None: skipped_entries[pair] += len(row.entry_ids) continue if row.account_id in selection.company_1_account_ids: item = amounts_1[pair] item["amount"] += _get_amount(row, selection.measure, "D") item["entry_osv_ids"].update(row.entry_ids) if row.account_id in selection.company_2_account_ids: item = amounts_2[pair] item["amount"] += _get_amount(row, selection.measure, "K") item["entry_osv_ids"].update(row.entry_ids) cells: list[IntercompanyMatrixCellOut] = [] for company_a in company_ids: for company_b in company_ids: if company_a == company_b: continue item_a = amounts_1.get((company_a, company_b)) item_b = amounts_2.get((company_b, company_a)) amount_a = item_a["amount"] if item_a is not None else ZERO amount_b = item_b["amount"] if item_b is not None else ZERO variance_amount = abs(amount_a - amount_b) base = min(amount_a, amount_b) variance_percent = None if base == ZERO else variance_amount / base * 100 exceeds_threshold = variance_percent is not None and variance_percent > VARIANCE_THRESHOLD_PERCENT debit_ids = item_a["entry_osv_ids"] if item_a is not None else () credit_ids = item_b["entry_osv_ids"] if item_b is not None else () cells.append( IntercompanyMatrixCellOut( company_a_id=company_a, company_b_id=company_b, amount_a=amount_a, amount_b=amount_b, variance_amount=variance_amount, variance_percent=variance_percent, exceeds_threshold=exceeds_threshold, one_sided=bool(debit_ids) != bool(credit_ids), skipped_osv_entry_count=skipped_entries[(company_a, company_b)] + skipped_entries[(company_b, company_a)], osv_entry_ids_debit=debit_ids, osv_entry_ids_credit=credit_ids, ) ) return IntercompanyMatrixOut(companies=list(company_info), cells=cells) def get_unpaired_rows(rows: Iterable[ConsolidatedRow], matrix: IntercompanyMatrixOut) -> list[ConsolidatedRow]: """Строки свода, попавшие только на одну сторону пары компаний в шахматке.""" entry_ids = { entry_id for cell in matrix.cells if cell.one_sided for entry_id in (*cell.osv_entry_ids_debit, *cell.osv_entry_ids_credit) } return [row for row in rows if entry_ids.intersection(row.entry_ids)] def _make_account_tree( accounts: Sequence[ChartOfAccount], ) -> tuple[dict[uuid.UUID, ChartOfAccount], dict[uuid.UUID, list[uuid.UUID]]]: by_id = {account.id: account for account in accounts} children: dict[uuid.UUID, list[uuid.UUID]] = defaultdict(list) for account in accounts: if account.parent_id is not None: children[account.parent_id].append(account.id) return by_id, children def _expand(roots: Iterable[uuid.UUID], children: dict[uuid.UUID, list[uuid.UUID]]) -> set[uuid.UUID]: """Выбранные счета и все их потомки.""" result: set[uuid.UUID] = set() pending = list(roots) while pending: account_id = pending.pop() if account_id not in result: result.add(account_id) pending.extend(children.get(account_id, ())) return result def _get_kinds(side: Side) -> frozenset[AccountKind]: return DEBIT_KINDS if side == "D" else CREDIT_KINDS def _select_accounts( included: Iterable[uuid.UUID], excluded: Iterable[uuid.UUID], side: Side, by_id: dict[uuid.UUID, ChartOfAccount], children: dict[uuid.UUID, list[uuid.UUID]], ) -> frozenset[uuid.UUID]: roots = set(included) missing = roots - by_id.keys() if missing: missing_ids = ", ".join(map(str, sorted(missing))) raise DomainValidationError("ACCOUNT_NOT_FOUND", f"Выбранного счёта нет ({missing_ids})") if any(by_id[account_id].kind not in _get_kinds(side) for account_id in roots): raise DomainValidationError("ACCOUNT_KIND_MISMATCH", "Сторона счёта не соответствует направлению расчёта") selected = _expand(roots, children) - _expand(excluded, children) return frozenset(account_id for account_id in selected if by_id[account_id].kind in _get_kinds(side)) def calculate_result_table( rows: Iterable[ConsolidatedRow], member_companies: Sequence[IntercompanyMatrixCompanyOut], accounts: Sequence[ChartOfAccount], ) -> IntercompanyResultTableOut: """Фиксированные статьи ВГО: сальдо по всем контрагентам периметра для каждого ЮЛ.""" by_id, children = _make_account_tree(accounts) by_code = {account.code: account.id for account in accounts} required_codes = {code for article in RESULT_ARTICLES for code in (*article.account_codes, *article.excluded_codes)} missing = sorted(required_codes - by_code.keys()) if missing: raise DomainValidationError("RESULT_ACCOUNTS_MISSING", f"В плане счетов нет счетов: {', '.join(missing)}") selected = { article.code: _select_accounts( (by_code[code] for code in article.account_codes), (by_code[code] for code in article.excluded_codes), article.side, by_id, children, ) for article in RESULT_ARTICLES } companies = tuple({company.id: company for company in member_companies}.values()) member_ids = {company.id for company in companies} amounts: dict[str, dict[uuid.UUID, Decimal]] = { article.code: defaultdict(lambda: ZERO) for article in RESULT_ARTICLES } for row in rows: if ( row.legal_entity_id not in member_ids or row.counterparty_id not in member_ids or row.legal_entity_id == row.counterparty_id or row.account_id is None ): continue for article in RESULT_ARTICLES: if row.account_id in selected[article.code]: amounts[article.code][row.legal_entity_id] += _get_amount(row, "closing", article.side) articles: list[IntercompanyResultArticleOut] = [] for article in RESULT_ARTICLES: company_amounts = [ IntercompanyResultAmountOut(company_id=company.id, amount=amounts[article.code].get(company.id, ZERO)) for company in companies ] articles.append( IntercompanyResultArticleOut( code=article.code, name=article.name, amounts=company_amounts, group_total=sum((item.amount for item in company_amounts), ZERO), ) ) return IntercompanyResultTableOut(companies=list(companies), articles=articles) class IntercompanyMatrixService: """Получает свод и периметр версии, затем вызывает чистый расчёт матрицы.""" def __init__(self, session: AsyncSession, scope: VisibilityScope, service_osv: ConsolidatedOsvService) -> None: self._session = session self._scope = scope self._osv_service = service_osv async def build( self, version_id: uuid.UUID, selection: MatrixSelection, ) -> IntercompanyMatrixOut: """Счета принимает готовыми; выбор режима ALL/MANUAL выполняется вызывающим кодом.""" result = await self._osv_service.build_with_members(version_id) return self._calculate(result.consolidated.rows, result.members, selection) def _calculate( self, rows: Iterable[ConsolidatedRow], members: dict[uuid.UUID, Entity], selection: MatrixSelection ) -> IntercompanyMatrixOut: """Общий вызов расчёта с участниками из уже построенного свода.""" companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in members.values()] return calculate_matrix(rows, companies, selection) async def build_result_table(self, version_id: uuid.UUID) -> IntercompanyResultTableOut: """Статичная таблица по пяти статьям""" result = await self._osv_service.build_with_members(version_id) accounts = await directory_repo.list_accounts(self._session) companies = [IntercompanyMatrixCompanyOut(id=entity.id, name=entity.name) for entity in result.members.values()] return calculate_result_table(result.consolidated.rows, companies, accounts) async def build_accounts( self, version_id: uuid.UUID, *, mode: Literal["ALL", "MANUAL", "PRESET"], debit_account_ids: Sequence[uuid.UUID] = (), credit_account_ids: Sequence[uuid.UUID] = (), rule_id: uuid.UUID | None = None, ) -> IntercompanyMatrixOut: """Построить шахматку по выбранному режиму. ALL включает все счета соответствующей стороны; MANUAL — выбранные счета и их потомков; PRESET берёт счета и показатель из правила с идентификатором `rule_id`. """ result = await self._build_accounts( version_id, mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids, rule_id=rule_id, ) return result.matrix async def build_export( self, version_id: uuid.UUID, *, mode: Literal["ALL", "MANUAL", "PRESET"], debit_account_ids: Sequence[uuid.UUID] = (), credit_account_ids: Sequence[uuid.UUID] = (), rule_id: uuid.UUID | None = None, ) -> tuple[IntercompanyMatrixOut, MatrixExportInfo]: """Матрица и реквизиты книги из одного расчёта свода.""" result = await self._build_accounts( version_id, mode=mode, debit_account_ids=debit_account_ids, credit_account_ids=credit_account_ids, rule_id=rule_id, ) group_name = ( await self._session.execute( select(CompanyGroup.name).where(CompanyGroup.id == result.consolidation.company_group_id) ) ).scalar_one() by_id = {account.id: account.code for account in result.accounts} if mode == "PRESET": debit_codes = tuple(sorted(by_id[account_id] for account_id in result.selection.company_1_account_ids)) credit_codes = tuple(sorted(by_id[account_id] for account_id in result.selection.company_2_account_ids)) else: debit_codes = tuple(by_id[account_id] for account_id in debit_account_ids) credit_codes = tuple(by_id[account_id] for account_id in credit_account_ids) info = MatrixExportInfo( group_name=group_name, year=result.consolidation.year, quarter=result.consolidation.quarter, version_no=result.version.version_no, mode=mode, debit_account_codes=debit_codes, credit_account_codes=credit_codes, measure=result.selection.measure, rule_id=result.rule.id if result.rule is not None else None, rule_no=result.rule.rule_no if result.rule is not None else None, rule_version_no=result.rule.version_no if result.rule is not None else None, ) return result.matrix, info async def get_unpaired_osv(self, version_id: uuid.UUID, rule_id: uuid.UUID) -> UnpairedOsvOut: """Все строки свода без встречной стороны в шахматке заданного пресета.""" result = await self._build_accounts( version_id, mode="PRESET", debit_account_ids=(), credit_account_ids=(), rule_id=rule_id ) unmatched = get_unpaired_rows(result.rows, result.matrix) return UnpairedOsvOut(items=[row_out(row) for row in unmatched], total=len(unmatched)) async def _resolve_preset_selection( self, rule_id: uuid.UUID | None, version: ConsolidationVersion, by_id: dict[uuid.UUID, ChartOfAccount], children: dict[uuid.UUID, list[uuid.UUID]], ) -> tuple[MatrixSelection, EliminationRule]: """Выбрать счета и показатель пресета для версии консолидации.""" if rule_id is None: raise DomainValidationError("RULE_REQUIRED", "Выберите правило-пресет") rule, rule_accounts = await elimination_rules_repo.rule_with_account(self._session, rule_id) if rule is None or not elimination_rules_repo.is_valid_for_version(rule, version.elimination_rules_version): raise NotFoundError("правило-пресет не найдено для версии консолидации") if version.elimination_rules_version is None: open_version_no = await elimination_rules_repo.current_open_version_no(self._session) if rule.version_no != open_version_no: raise NotFoundError("правило-пресет не найдено для версии консолидации") if rule.statement_type != StatementType.balance: raise DomainValidationError("INVALID_RULE", "Шахматка PRESET поддерживает только правила ББ") if rule.side_1 not in ("D", "K") or rule.measure not in ("closing", "turnover"): raise DomainValidationError("INVALID_RULE", "Неверное направление или показатель правила-пресета") sides: dict[int, tuple[list[uuid.UUID], list[uuid.UUID]]] = { 1: ([], []), 2: ([], []), } for rule_account in rule_accounts: if rule_account.company_no not in sides: raise DomainValidationError("INVALID_RULE", "Неверная сторона счёта в правиле-пресете") included, excluded = sides[rule_account.company_no] (excluded if rule_account.is_excluded else included).append(rule_account.account_id) debit_side = 1 if rule.side_1 == "D" else 2 credit_side = 2 if debit_side == 1 else 1 debit_ids, debit_excluded = sides[debit_side] credit_ids, credit_excluded = sides[credit_side] if not debit_ids or not credit_ids: raise DomainValidationError("ACCOUNTS_REQUIRED", "В правиле-пресете нужны счета обеих сторон") debit = _select_accounts(debit_ids, debit_excluded, "D", by_id, children) credit = _select_accounts(credit_ids, credit_excluded, "K", by_id, children) measure: Measure = "closing" if rule.measure == "closing" else "turnover" return MatrixSelection(debit, credit, measure), rule async def _build_accounts( self, version_id: uuid.UUID, *, mode: Literal["ALL", "MANUAL", "PRESET"], debit_account_ids: Sequence[uuid.UUID], credit_account_ids: Sequence[uuid.UUID], rule_id: uuid.UUID | None = None, ) -> MatrixBuildResult: osv_result = await self._osv_service.build_with_members(version_id) rows = osv_result.consolidated.rows accounts = await directory_repo.list_accounts(self._session) by_id, children = _make_account_tree(accounts) rule: EliminationRule | None = None if mode == "ALL": if debit_account_ids or credit_account_ids: raise DomainValidationError("ACCOUNTS_NOT_ALLOWED", "Для режима ALL не нужно указывать счета") debit = frozenset(account.id for account in accounts if account.kind in DEBIT_KINDS) credit = frozenset(account.id for account in accounts if account.kind in CREDIT_KINDS) selection = MatrixSelection(debit, credit, "closing") elif mode == "MANUAL": if not debit_account_ids or not credit_account_ids: raise DomainValidationError("ACCOUNTS_REQUIRED", "Выберите дебетовые и кредитовые счета") debit = _select_accounts(debit_account_ids, (), "D", by_id, children) credit = _select_accounts(credit_account_ids, (), "K", by_id, children) selection = MatrixSelection(debit, credit, "closing") elif mode == "PRESET": selection, rule = await self._resolve_preset_selection(rule_id, osv_result.version, by_id, children) else: raise DomainValidationError("INVALID_MODE", "Неизвестный режим выбора счетов") matrix = self._calculate(rows, osv_result.members, selection) return MatrixBuildResult( matrix=matrix, version=osv_result.version, consolidation=osv_result.consolidation, accounts=accounts, selection=selection, rows=rows, rule=rule, )