arm-core/api/src/domain/consolidated_osv.py
Andrey Epifancev d5a317a040 feat(domain): сводная ОСВ — выбор источников, приведение периодов, пробелы, признак контрагента
- consolidation_periods: QUARTER_ORDER (единственное определение), quarters_through,
  counts_as_cumulative (R-1), select_sources -> Sources | Gap (D-2)
- consolidated_osv: build (склейка квартальных по ключу, нарастающий как есть, пробелы,
  counterparty_id последнего квартала + warnings, дорезолв без записи, признак GSK/GK/EXTERNAL,
  порядок D-16), apply_filter, summary, classify
- checklist: _osv_coverage — адаптер над select_sources (T8.1), явная таблица статусов
  кандидатов (T8.4), QUARTER_ORDER/account_sort_key из домена (T8.5)
- consolidation_documents: QUARTER_ORDER и counts_as_cumulative из домена
- schemas: CounterpartyType — доменный StrEnum (T2.8)
- design.md: решения №50–56; план и tasks.md 4.1 отмечены

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 06:07:55 +04:00

506 lines
22 KiB
Python
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.

"""Сводная ОСВ версии консолидации (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<n>"
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