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