"""Сводная ОСВ версии консолидации (spec consolidated-osv; design D-1, D-2, D-3, D-16; §2, §3 `05-domain-rules.md`). Чистая функция от версии (DECISION-5): результат не хранится. Вход — структурные dataclass'ы без ORM (`SourceRow` — строка `osv_entry` `group_type = COUNTERPARTY`, `data_type IN (BU, SUM)`; `SourceDocument` — привязанный к версии ОСВ-документ; `Entity` — ЮЛ справочника), справочник для дорезолва — `Resolver` (`CounterpartyIndex` подходит). 1. Пары (ЮЛ периметра, счёт документа) → `consolidation_periods.select_sources`: нарастающий за квартал версии — строки «как есть» (`is_cumulative = True`, дубли ключа не схлопываются, суммы `None` остаются `None`); квартальные `Q1..Qn` — склейка по ключу (счёт строки, имя `strip()`, ИНН без пробелов): `opening_*` из `Q1`, `turnover_*` = Σ, `closing_*` из `Qn`, отсутствующая в квартале строка и сумма `None` — нули, дубли ключа в квартале суммируются; пробел квартала → `GapItem`, строк по паре нет. 2. Контрагент: непустой `counterparty_id` самого позднего квартала (расхождение с другими — в `warnings`, решение №8); пустой → `Resolver.resolve(inn, name)` на лету, `match_stored = False`, в БД не пишется. 3. Признак (`classify`): `GSK` — контрагент в периметре версии; `GK` — его ГК = ГК консолидации; иначе `EXTERNAL`. 4. Порядок строк: имя ЮЛ, счёт (натурально), номер строки (пустой — в конце), первый `entry_id`. Деньги — `Decimal`, `float` не используется. """ import enum import uuid from collections import defaultdict from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass from decimal import Decimal from typing import Protocol from src.domain.consolidation_periods import Gap, quarters_through, select_sources from src.domain.inn import normalize_inn from src.domain.osv_mapping import Resolution, account_sort_key from src.models.enums import CounterpartyMatchKind, PeriodQuarter __all__ = [ "AUTO_MATCH_KINDS", "Consolidated", "ConsolidatedRow", "CounterpartyType", "Entity", "GapItem", "OsvFilter", "Resolver", "SourceDocument", "SourceRow", "apply_filter", "build", "classify", "glue_key", "summary", ] ZERO = Decimal("0") #: Способы автоматического сопоставления по имени — счётчик `auto_matched_total` (независимо от `match_stored`). AUTO_MATCH_KINDS = frozenset({CounterpartyMatchKind.NAME, CounterpartyMatchKind.ALIAS}) class CounterpartyType(enum.StrEnum): """Признак контрагента относительно версии (§3 п. 5; единственное определение: схема API берёт его отсюда).""" GSK = "GSK" # ЮЛ периметра версии (consolidation_member) GK = "GK" # ЮЛ той же ГК, вне периметра EXTERNAL = "EXTERNAL" # прочие, в том числе несопоставленные @dataclass(frozen=True) class Entity: """ЮЛ справочника: имя, ИНН и ГК (`None` — монозаёмщик).""" id: uuid.UUID name: str inn: str company_group_id: uuid.UUID | None @dataclass(frozen=True) class SourceDocument: """ОСВ-документ, привязанный к версии (`consolidation_document`); поля — как в DBML `document`.""" id: uuid.UUID legal_entity_id: uuid.UUID osv_account_code: str | None period_quarter: PeriodQuarter | None is_cumulative: bool | None @dataclass(frozen=True) class SourceRow: """Строка `osv_entry` (`group_type = COUNTERPARTY`, `data_type IN (BU, SUM)`) — как хранится.""" id: uuid.UUID document_id: uuid.UUID row_number: int | None account_code: str | None account_root: str | None counterparty_name: str | None counterparty_inn: str | None counterparty_id: uuid.UUID | None match_kind: CounterpartyMatchKind | None opening_debit: Decimal | None opening_credit: Decimal | None turnover_debit: Decimal | None turnover_credit: Decimal | None closing_debit: Decimal | None closing_credit: Decimal | None @dataclass(frozen=True) class ConsolidatedRow: """Строка сводной ОСВ (D-16): исходные `osv_entry`, ЮЛ, счёт, период, контрагент как в файле и после резолва, признак, способ сопоставления, шесть сумм, предупреждения.""" entry_ids: tuple[uuid.UUID, ...] legal_entity_id: uuid.UUID legal_entity_name: str legal_entity_inn: str account_code: str | None account_root: str | None period: str # "<год> Q" is_cumulative: bool row_number: int | None counterparty_name: str | None counterparty_inn: str | None counterparty_id: uuid.UUID | None counterparty_entity_name: str | None counterparty_type: CounterpartyType match_kind: CounterpartyMatchKind | None match_stored: bool # `False` — найдено дорезолвом на чтении (или не найдено), в `osv_entry` не записано opening_debit: Decimal | None opening_credit: Decimal | None turnover_debit: Decimal | None turnover_credit: Decimal | None closing_debit: Decimal | None closing_credit: Decimal | None warnings: tuple[str, ...] = () @dataclass(frozen=True) class GapItem: """Пробел свода: у ЮЛ по счёту нет квартальных ОСВ за `missing_quarters` — строк по паре нет.""" legal_entity_id: uuid.UUID legal_entity_name: str account_code: str missing_quarters: tuple[PeriodQuarter, ...] @dataclass(frozen=True) class Consolidated: """Свод версии: строки в порядке D-16, пробелы (по имени ЮЛ и счёту), число привязанных ОСВ-документов.""" rows: tuple[ConsolidatedRow, ...] gaps: tuple[GapItem, ...] documents_total: int class Resolver(Protocol): """Справочник контрагентов для дорезолва (§3 п. 4): ИНН → точное имя → нормализованное имя/алиас.""" def resolve(self, inn: str | None, name: str | None) -> Resolution | None: ... @dataclass(frozen=True) class OsvFilter: """Фильтры свода (`GET …/osv`, экспорт): пустые поля не фильтруют; `q` — подстрока имени без регистра или префикс ИНН контрагента; `unmapped` — `counterparty_id` пуст после дорезолва (`False` — только сопоставленные).""" legal_entity_id: uuid.UUID | None = None account_root: str | None = None counterparty_type: CounterpartyType | None = None unmapped: bool | None = None match_kinds: frozenset[CounterpartyMatchKind] = frozenset() q: str | None = None GlueKey = tuple[str, str, str] def glue_key(row: SourceRow) -> GlueKey: """Ключ склейки квартальных строк (D-2): счёт строки, имя контрагента без краевых пробелов, ИНН без пробелов.""" return (row.account_code or "", (row.counterparty_name or "").strip(), normalize_inn(row.counterparty_inn or "")) def classify( counterparty_id: uuid.UUID | None, *, members: Mapping[uuid.UUID, Entity], entities: Mapping[uuid.UUID, Entity], group_id: uuid.UUID, ) -> CounterpartyType: """Признак контрагента относительно версии (D-3): `GSK` — в периметре; `GK` — ЮЛ ГК консолидации вне периметра; иначе (пустой, чужая ГК, монозаёмщик, неизвестный id) — `EXTERNAL`.""" if counterparty_id is None: return CounterpartyType.EXTERNAL if counterparty_id in members: return CounterpartyType.GSK entity = entities.get(counterparty_id) if entity is not None and entity.company_group_id == group_id: return CounterpartyType.GK return CounterpartyType.EXTERNAL # --------------------------------------------------------------------------- суммы @dataclass(frozen=True) class _Amounts: """Шесть сумм строки; `None` при сложении — ноль.""" opening_debit: Decimal = ZERO opening_credit: Decimal = ZERO turnover_debit: Decimal = ZERO turnover_credit: Decimal = ZERO closing_debit: Decimal = ZERO closing_credit: Decimal = ZERO @classmethod def of(cls, row: SourceRow) -> "_Amounts": return cls( row.opening_debit or ZERO, row.opening_credit or ZERO, row.turnover_debit or ZERO, row.turnover_credit or ZERO, row.closing_debit or ZERO, row.closing_credit or ZERO, ) @classmethod def total(cls, rows: Iterable[SourceRow]) -> "_Amounts": result = cls() for row in rows: part = cls.of(row) result = cls( result.opening_debit + part.opening_debit, result.opening_credit + part.opening_credit, result.turnover_debit + part.turnover_debit, result.turnover_credit + part.turnover_credit, result.closing_debit + part.closing_debit, result.closing_credit + part.closing_credit, ) return result @dataclass(frozen=True) class _Values: """Суммы строки свода: у нарастающего — как в файле (с `None`), у склейки — `Decimal`.""" opening_debit: Decimal | None opening_credit: Decimal | None turnover_debit: Decimal | None turnover_credit: Decimal | None closing_debit: Decimal | None closing_credit: Decimal | None def _as_is(row: SourceRow) -> _Values: return _Values( row.opening_debit, row.opening_credit, row.turnover_debit, row.turnover_credit, row.closing_debit, row.closing_credit, ) def _glued(first: _Amounts, turnover: _Amounts, last: _Amounts) -> _Values: """§2: `opening_*` — из первого квартала, `turnover_*` — сумма, `closing_*` — из последнего.""" return _Values( first.opening_debit, first.opening_credit, turnover.turnover_debit, turnover.turnover_credit, last.closing_debit, last.closing_credit, ) # --------------------------------------------------------------------------- сборка строки @dataclass(frozen=True) class _Context: period: str group_id: uuid.UUID members: Mapping[uuid.UUID, Entity] entities: Mapping[uuid.UUID, Entity] resolver: Resolver QuarterRows = Sequence[tuple[PeriodQuarter, Sequence[SourceRow]]] def _row_order(row: SourceRow) -> tuple[bool, int, uuid.UUID]: """Порядок строк внутри документа/квартала: номер строки (пустой — в конце), затем `id` (детерминизм).""" return (row.row_number is None, row.row_number or 0, row.id) def _stored_counterparty(quarters: QuarterRows) -> tuple[SourceRow | None, tuple[str, ...]]: """Строка-источник `counterparty_id` (D-3, решение №8): первая сопоставленная в самом позднем квартале, где они есть; строки с другим непустым `counterparty_id` — в `warnings` (пустые — не расхождение).""" mapped = [(quarter, row) for quarter, rows in quarters for row in rows if row.counterparty_id is not None] if not mapped: return None, () latest = mapped[-1][0] chosen = next(row for quarter, row in mapped if quarter == latest) warnings = tuple( f"{quarter.value}: строка {row.id} сопоставлена с {row.counterparty_id}, в своде — {chosen.counterparty_id}" for quarter, row in mapped if row.counterparty_id != chosen.counterparty_id ) return chosen, warnings def _compose( # noqa: PLR0913 - сборка строки свода из частей: ЮЛ, источники, представитель, суммы, признак склейки context: _Context, entity: Entity, quarters: QuarterRows, representative: SourceRow, values: _Values, *, is_cumulative: bool, ) -> ConsolidatedRow: """Строка свода: «как в файле» — из `representative`; контрагент — хранимый или дорезолв на лету.""" stored, warnings = _stored_counterparty(quarters) counterparty_id: uuid.UUID | None match_kind: CounterpartyMatchKind | None if stored is not None: counterparty_id, match_kind, match_stored = stored.counterparty_id, stored.match_kind, True else: # дорезолв на чтение (D-3): результат показывается, но не пишется resolution = context.resolver.resolve(representative.counterparty_inn, representative.counterparty_name) counterparty_id = resolution.entity_id if resolution is not None else None match_kind = resolution.kind if resolution is not None else None match_stored = False counterparty = None if counterparty_id is not None: counterparty = context.entities.get(counterparty_id) or context.members.get(counterparty_id) return ConsolidatedRow( entry_ids=tuple(row.id for _, rows in quarters for row in rows), legal_entity_id=entity.id, legal_entity_name=entity.name, legal_entity_inn=entity.inn, account_code=representative.account_code, account_root=representative.account_root, period=context.period, is_cumulative=is_cumulative, row_number=representative.row_number, counterparty_name=representative.counterparty_name, counterparty_inn=representative.counterparty_inn, counterparty_id=counterparty_id, counterparty_entity_name=counterparty.name if counterparty is not None else None, counterparty_type=classify( counterparty_id, members=context.members, entities=context.entities, group_id=context.group_id ), match_kind=match_kind, match_stored=match_stored, opening_debit=values.opening_debit, opening_credit=values.opening_credit, turnover_debit=values.turnover_debit, turnover_credit=values.turnover_credit, closing_debit=values.closing_debit, closing_credit=values.closing_credit, warnings=warnings, ) def _cumulative_rows( context: _Context, entity: Entity, documents: Sequence[SourceDocument], rows_by_document: Mapping[uuid.UUID, Sequence[SourceRow]], ) -> list[ConsolidatedRow]: """Нарастающий документ: строка свода = строка файла (§3 п. 3, 6), суммы как есть.""" result = [] for document in documents: quarter = document.period_quarter or PeriodQuarter.Q1 # select_sources отдаёт только документы с кварталом for row in rows_by_document.get(document.id, ()): result.append(_compose(context, entity, [(quarter, [row])], row, _as_is(row), is_cumulative=True)) return result def _glued_rows( context: _Context, entity: Entity, needed: Sequence[PeriodQuarter], documents: Sequence[SourceDocument], rows_by_document: Mapping[uuid.UUID, Sequence[SourceRow]], ) -> list[ConsolidatedRow]: """Квартальные документы `Q1..Qn`: склейка по `glue_key` (§2, D-2, решение №1).""" by_key: dict[GlueKey, dict[PeriodQuarter, list[SourceRow]]] = defaultdict(lambda: defaultdict(list)) for document in documents: if document.period_quarter is None: # select_sources отдаёт только документы с кварталом continue for row in rows_by_document.get(document.id, ()): by_key[glue_key(row)][document.period_quarter].append(row) result = [] first, last = needed[0], needed[-1] for per_quarter in by_key.values(): quarters = [(q, sorted(per_quarter[q], key=_row_order)) for q in needed if q in per_quarter] # «как в файле» и номер строки — из последнего квартала, где ключ есть (минимальный номер строки в нём) representative = quarters[-1][1][0] values = _glued( _Amounts.total(per_quarter.get(first, ())), _Amounts.total(row for _, rows in quarters for row in rows), _Amounts.total(per_quarter.get(last, ())), ) result.append(_compose(context, entity, quarters, representative, values, is_cumulative=False)) return result def _row_sort_key(row: ConsolidatedRow) -> tuple[object, ...]: """D-16: имя ЮЛ, счёт (натурально), номер строки (пустой — в конце), первый `entry_id`.""" code = row.account_code or "" return ( row.legal_entity_name, row.legal_entity_id, account_sort_key(code), code, row.row_number is None, row.row_number or 0, row.entry_ids[0], ) def build( # noqa: PLR0913 - сигнатура зафиксирована планом: период, ГК, периметр, справочник, документы, строки *, year: int, quarter: PeriodQuarter, group_id: uuid.UUID, members: Mapping[uuid.UUID, Entity], entities: Mapping[uuid.UUID, Entity], documents: Sequence[SourceDocument], rows: Sequence[SourceRow], resolver: Resolver, ) -> Consolidated: """Свод версии за `year`/`quarter` ГК `group_id` с периметром `members` (D-1…D-3). `documents` — привязанные к версии ОСВ-документы (документы не членов периметра защитно игнорируются), `rows` — их строки (строки документов вне `documents` игнорируются), `entities` — весь справочник без policy (имя и ГК контрагента). Входной порядок на результат не влияет. """ context = _Context(f"{year} {quarter.value}", group_id, members, entities, resolver) rows_by_document: dict[uuid.UUID, list[SourceRow]] = defaultdict(list) for row in rows: rows_by_document[row.document_id].append(row) for document_rows in rows_by_document.values(): document_rows.sort(key=_row_order) pairs: dict[tuple[uuid.UUID, str], list[SourceDocument]] = defaultdict(list) for document in sorted(documents, key=lambda d: d.id): if document.legal_entity_id in members: pairs[(document.legal_entity_id, document.osv_account_code or "")].append(document) result: list[ConsolidatedRow] = [] gaps: list[GapItem] = [] for (entity_id, account), pair_documents in pairs.items(): entity = members[entity_id] selected = select_sources(quarter, pair_documents) if isinstance(selected, Gap): gaps.append(GapItem(entity.id, entity.name, account, selected.missing_quarters)) elif selected.cumulative: result.extend(_cumulative_rows(context, entity, selected.documents, rows_by_document)) else: needed = quarters_through(quarter) result.extend(_glued_rows(context, entity, needed, selected.documents, rows_by_document)) result.sort(key=_row_sort_key) gaps.sort(key=lambda g: (g.legal_entity_name, g.legal_entity_id, account_sort_key(g.account_code), g.account_code)) return Consolidated(rows=tuple(result), gaps=tuple(gaps), documents_total=len(documents)) # --------------------------------------------------------------------------- фильтры и сводка def _matches_query(row: ConsolidatedRow, q: str | None) -> bool: text = (q or "").strip() if not text: return True if text.casefold() in (row.counterparty_name or "").casefold(): return True return row.counterparty_inn is not None and normalize_inn(row.counterparty_inn).startswith(normalize_inn(text)) def _matches(row: ConsolidatedRow, f: OsvFilter) -> bool: # noqa: PLR0911 - по одному выходу на фильтр if f.legal_entity_id is not None and row.legal_entity_id != f.legal_entity_id: return False if f.account_root is not None and row.account_root != f.account_root: return False if f.counterparty_type is not None and row.counterparty_type != f.counterparty_type: return False if f.unmapped is not None and (row.counterparty_id is None) != f.unmapped: return False if f.match_kinds and row.match_kind not in f.match_kinds: return False return _matches_query(row, f.q) def apply_filter(rows: Iterable[ConsolidatedRow], f: OsvFilter) -> list[ConsolidatedRow]: """Строки, прошедшие все заданные фильтры (порядок сохраняется).""" return [row for row in rows if _matches(row, f)] def summary(c: Consolidated) -> tuple[int, int, int]: """Сводка свода: `rows_total`, `unmapped_total` (`counterparty_id` пуст после дорезолва), `auto_matched_total` (`match_kind` ∈ {`NAME`, `ALIAS`} независимо от `match_stored`).""" unmapped = sum(1 for row in c.rows if row.counterparty_id is None) auto_matched = sum(1 for row in c.rows if row.match_kind in AUTO_MATCH_KINDS) return len(c.rows), unmapped, auto_matched