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
This commit is contained in:
Andrey Epifancev 2026-10-06 06:07:55 +04:00
parent f46cf2fec5
commit d5a317a040
12 changed files with 1252 additions and 48 deletions

View File

@ -17,23 +17,21 @@
""" """
import enum import enum
import re
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from collections.abc import Iterable, Mapping, Sequence from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from typing import Protocol, TypeVar from typing import Protocol, TypeVar
from src.domain.consolidation_periods import Sources, quarters_through, select_sources
from src.domain.osv_mapping import account_sort_key
from src.models.enums import DocumentStatus, DocumentType, FileStatus, PeriodQuarter from src.models.enums import DocumentStatus, DocumentType, FileStatus, PeriodQuarter
__all__ = ["Cell", "CellStatus", "EntityFilesState", "Rule", "build", "order_rules"] __all__ = ["Cell", "CellStatus", "EntityFilesState", "Rule", "build", "order_rules"]
_QUARTERS = tuple(PeriodQuarter)
_QUARTER_ORDER = {quarter: position for position, quarter in enumerate(_QUARTERS)}
# порядок столбцов: ББ, ОФР, ОСВ по коду счёта; прочие типы (в правилах подходов их нет) — в конце # порядок столбцов: ББ, ОФР, ОСВ по коду счёта; прочие типы (в правилах подходов их нет) — в конце
_TYPE_ORDER = {DocumentType.BALANCE: 0, DocumentType.INCOME: 1, DocumentType.OSV: 2} _TYPE_ORDER = {DocumentType.BALANCE: 0, DocumentType.INCOME: 1, DocumentType.OSV: 2}
_UNORDERED = len(_TYPE_ORDER) _UNORDERED = len(_TYPE_ORDER)
_UNCONFIRMED_PRIORITY = {DocumentStatus.NEEDS_CHECK: 0, DocumentStatus.RECOGNIZED: 1}
class CellStatus(enum.StrEnum): class CellStatus(enum.StrEnum):
@ -47,6 +45,15 @@ class CellStatus(enum.StrEnum):
MISSING = "MISSING" MISSING = "MISSING"
# неподтверждённый документ → статус ячейки (T8.4: явная таблица вместо равенства строк); порядок ключей — приоритет:
# `NEEDS_CHECK` важнее `RECOGNIZED`
_UNCONFIRMED_STATUS: dict[DocumentStatus, CellStatus] = {
DocumentStatus.NEEDS_CHECK: CellStatus.NEEDS_CHECK,
DocumentStatus.RECOGNIZED: CellStatus.RECOGNIZED,
}
_UNCONFIRMED_PRIORITY = {status: position for position, status in enumerate(_UNCONFIRMED_STATUS)}
@dataclass(frozen=True) @dataclass(frozen=True)
class EntityFilesState: class EntityFilesState:
"""Состояние файлов ЮЛ: `has_in_progress` — есть файл `UPLOADED`/`PROCESSING`; `last_file_status` — статус """Состояние файлов ЮЛ: `has_in_progress` — есть файл `UPLOADED`/`PROCESSING`; `last_file_status` — статус
@ -101,16 +108,11 @@ class DocumentLike(Protocol):
def status(self) -> DocumentStatus: ... def status(self) -> DocumentStatus: ...
def _natural_key(code: str) -> tuple[tuple[int, int, str], ...]:
"""Натуральный ключ кода счёта: числовые части — по значению (`01 < 58 < 58.03 < 60`), прочие — по тексту."""
return tuple((0, int(part), "") if part.isdigit() else (1, 0, part) for part in re.split(r"[.\s]+", code) if part)
def order_rules(rules: Iterable[Rule]) -> list[Rule]: def order_rules(rules: Iterable[Rule]) -> list[Rule]:
"""Порядок столбцов чек-листа: `BALANCE`, `INCOME`, затем ОСВ по натуральной сортировке кода счёта.""" """Порядок столбцов чек-листа: `BALANCE`, `INCOME`, затем ОСВ по натуральной сортировке кода счёта."""
return sorted( return sorted(
rules, rules,
key=lambda rule: (_TYPE_ORDER.get(rule.document_type, _UNORDERED), _natural_key(rule.account or "")), key=lambda rule: (_TYPE_ORDER.get(rule.document_type, _UNORDERED), account_sort_key(rule.account or "")),
) )
@ -124,28 +126,20 @@ class _Coverage:
def _osv_coverage(version_quarter: PeriodQuarter, documents: Sequence[DocumentLike]) -> _Coverage: def _osv_coverage(version_quarter: PeriodQuarter, documents: Sequence[DocumentLike]) -> _Coverage:
"""Покрытие ОСВ одного счёта ЮЛ: нарастающий за квартал версии, либо квартальные Q1..Qn версии все; нарастающий """Покрытие ОСВ одного счёта ЮЛ — адаптер над `consolidation_periods.select_sources` (D-2, T8.1).
приоритетнее квартальных; пробел — `missing_quarters` и документы имеющихся кварталов.
Для версии Q1 квартальный Q1 равен нарастающему. Документы вне квартала версии (нарастающий за другой квартал, Полное покрытие — источники без пробела (нарастающий за квартал версии или квартальные `Q1..Qn` все). При пробеле
квартальный позже версии) покрытия не дают. `Gap` документов не несёт, поэтому адаптер сам берёт документы имеющихся кварталов (квартальные `Q1..Qn` в порядке
кварталов) и перечисляет `missing_quarters` только при частичном покрытии: без единого документа — `()` (№46).
# Task 9 заменит на consolidation_periods.select_sources
""" """
cumulative = [d for d in documents if d.is_cumulative is not False and d.period_quarter == version_quarter] selected = select_sources(version_quarter, documents)
if cumulative: if isinstance(selected, Sources):
return _Coverage(True, (), tuple(d.id for d in cumulative)) return _Coverage(True, (), tuple(d.id for d in selected.documents))
last = _QUARTER_ORDER[version_quarter] needed = quarters_through(version_quarter)
needed = _QUARTERS[: last + 1] present = tuple(
by_quarter: dict[PeriodQuarter, list[DocumentLike]] = defaultdict(list) d.id for quarter in needed for d in documents if d.is_cumulative is False and d.period_quarter == quarter
for document in documents: )
if document.is_cumulative is False and document.period_quarter in needed and document.period_quarter: return _Coverage(False, selected.missing_quarters if present else (), present)
by_quarter[document.period_quarter].append(document)
present = [d.id for quarter in needed for d in by_quarter.get(quarter, [])]
missing = tuple(quarter for quarter in needed if quarter not in by_quarter)
if not missing:
return _Coverage(True, (), tuple(present))
return _Coverage(False, missing if present else (), tuple(present))
def _coverage(version_quarter: PeriodQuarter, rule: Rule, documents: Sequence[DocumentLike]) -> _Coverage: def _coverage(version_quarter: PeriodQuarter, rule: Rule, documents: Sequence[DocumentLike]) -> _Coverage:
@ -176,10 +170,10 @@ def _cell(
def partial(status: CellStatus) -> Cell: def partial(status: CellStatus) -> Cell:
return Cell(status, coverage.missing_quarters, coverage.document_ids) return Cell(status, coverage.missing_quarters, coverage.document_ids)
candidates = [d for d in unbound if _same_slot(d, rule) and d.status in _UNCONFIRMED_PRIORITY] candidates = [d for d in unbound if _same_slot(d, rule) and d.status in _UNCONFIRMED_STATUS]
if candidates: if candidates:
best = min(candidates, key=lambda d: _UNCONFIRMED_PRIORITY[d.status]) best = min(candidates, key=lambda d: _UNCONFIRMED_PRIORITY[d.status])
return partial(CellStatus(best.status.value)) return partial(_UNCONFIRMED_STATUS[best.status])
if state.has_in_progress: if state.has_in_progress:
return partial(CellStatus.PROCESSING) return partial(CellStatus.PROCESSING)
if state.last_file_status == FileStatus.ERROR: if state.last_file_status == FileStatus.ERROR:

View File

@ -0,0 +1,505 @@
"""Сводная ОСВ версии консолидации (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

View File

@ -1,13 +1,31 @@
"""Период консолидации: отчётная дата по году и кварталу (spec consolidations-api «Создание консолидации»). """Период консолидации: отчётная дата и выбор источников ОСВ (spec consolidations-api «Создание консолидации»;
spec consolidated-osv «Выбор источников и приведение периодов»; design D-2; §2 `05-domain-rules.md`).
Чистая функция без БД. ASSUMPTION(OQ-40): отчётная дата — первый день месяца, следующего за кварталом. Чистые функции без БД и ORM: документы приходят структурно (`DocLike` — `Document`, `SourceDocument`, тестовые
dataclass'ы). ASSUMPTION(OQ-40): отчётная дата — первый день месяца, следующего за кварталом.
""" """
import datetime import datetime
import uuid
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Generic, Protocol, TypeVar
from src.models.enums import PeriodQuarter from src.models.enums import PeriodQuarter
__all__ = ["reporting_date"] __all__ = [
"QUARTER_ORDER",
"DocLike",
"Gap",
"Sources",
"counts_as_cumulative",
"quarters_through",
"reporting_date",
"select_sources",
]
#: Порядок кварталов года — единственное определение (T8.5): чек-лист и привязка документов берут его отсюда.
QUARTER_ORDER: tuple[PeriodQuarter, ...] = (PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4)
# квартал → (месяц отчётной даты, сдвиг года): Q4 заканчивается 31 декабря, отчётная дата — 1 января следующего года # квартал → (месяц отчётной даты, сдвиг года): Q4 заканчивается 31 декабря, отчётная дата — 1 января следующего года
_REPORTING_MONTH: dict[PeriodQuarter, tuple[int, int]] = { _REPORTING_MONTH: dict[PeriodQuarter, tuple[int, int]] = {
@ -25,3 +43,72 @@ def reporting_date(year: int, quarter: PeriodQuarter) -> datetime.date:
""" """
month, year_shift = _REPORTING_MONTH[quarter] # ASSUMPTION(OQ-40) month, year_shift = _REPORTING_MONTH[quarter] # ASSUMPTION(OQ-40)
return datetime.date(year + year_shift, month, 1) return datetime.date(year + year_shift, month, 1)
def quarters_through(quarter: PeriodQuarter) -> tuple[PeriodQuarter, ...]:
"""Кварталы года от `Q1` до `quarter` включительно (квартальные ОСВ, нужные версии за `quarter`)."""
return QUARTER_ORDER[: QUARTER_ORDER.index(quarter) + 1]
def counts_as_cumulative(is_cumulative: bool | None) -> bool:
"""Нарастающий ли документ: `is_cumulative` true или NULL (ББ/ОФР всегда с начала года, решение №10, R-1);
квартальный — только явный `False`."""
return is_cumulative is not False
class DocLike(Protocol):
"""Документ-кандидат в источники (структурный тип): поля — как в DBML `document`."""
@property
def id(self) -> uuid.UUID: ...
@property
def period_quarter(self) -> PeriodQuarter | None: ...
@property
def is_cumulative(self) -> bool | None: ...
D = TypeVar("D", bound=DocLike)
@dataclass(frozen=True)
class Sources(Generic[D]):
"""Источники пары (ЮЛ, счёт): нарастающий документ за квартал версии (`cumulative`) либо квартальные `Q1..Qn`
в порядке кварталов."""
documents: tuple[D, ...]
cumulative: bool
@dataclass(frozen=True)
class Gap:
"""Пробел пары (ЮЛ, счёт): нет квартальных ОСВ за `missing_quarters` (в порядке кварталов), строк по паре нет."""
missing_quarters: tuple[PeriodQuarter, ...]
def select_sources(version_quarter: PeriodQuarter, documents: Sequence[D]) -> Sources[D] | Gap:
"""Выбор источников одной пары (ЮЛ, счёт) по документам, привязанным к версии (D-2, §2, OQ-03).
1. Нарастающий (`is_cumulative` true/NULL) за квартал версии есть → он один (`cumulative = True`), квартальные
рядом игнорируются (нарастающий полнее по определению).
2. Иначе нужны квартальные (`is_cumulative = False`) за каждый квартал `Q1..Qn` версии; для версии `Q1` квартальный
`Q1` равнозначен нарастающему. Все есть → `Sources(cumulative=False)` в порядке кварталов (внутри квартала —
порядок входа).
3. Какого-то квартала нет → `Gap(missing_quarters)`; при отсутствии подходящих документов — все кварталы `Q1..Qn`.
Документы без квартала, нарастающие за другой квартал и квартальные позже версии источниками не бывают (такие
документы не привязываются, D-5; функция их защитно игнорирует). Дублей на квартал привязка не допускает (R-1):
если они всё же пришли, возвращаются все.
"""
cumulative = tuple(
d for d in documents if counts_as_cumulative(d.is_cumulative) and d.period_quarter == version_quarter
)
if cumulative:
return Sources(documents=cumulative, cumulative=True)
needed = quarters_through(version_quarter)
quarterly = [d for d in documents if d.is_cumulative is False and d.period_quarter in needed]
missing = tuple(quarter for quarter in needed if all(d.period_quarter != quarter for d in quarterly))
if missing:
return Gap(missing_quarters=missing)
ordered = tuple(d for quarter in needed for d in quarterly if d.period_quarter == quarter)
return Sources(documents=ordered, cumulative=False)

View File

@ -33,6 +33,7 @@ __all__ = [
"MappedSheet", "MappedSheet",
"Resolution", "Resolution",
"account_root", "account_root",
"account_sort_key",
"build_entries", "build_entries",
"counterparty_check", "counterparty_check",
"document_fields", "document_fields",
@ -51,6 +52,7 @@ _NUMERIC_SEGMENT_RE = re.compile(r"[0-9]{1,3}")
_ROOT_SEGMENT_RE = re.compile(r"[0-9]+") _ROOT_SEGMENT_RE = re.compile(r"[0-9]+")
_NON_DIGITS_RE = re.compile(r"[^0-9]") _NON_DIGITS_RE = re.compile(r"[^0-9]")
_SEGMENT_SEPARATOR = "." _SEGMENT_SEPARATOR = "."
_ACCOUNT_SPLIT_RE = re.compile(r"[.\s]+")
@dataclass(frozen=True) @dataclass(frozen=True)
@ -126,6 +128,13 @@ def account_root(code: str) -> str | None:
return first if _ROOT_SEGMENT_RE.fullmatch(first) else None return first if _ROOT_SEGMENT_RE.fullmatch(first) else None
def account_sort_key(code: str) -> tuple[tuple[int, int, str], ...]:
"""Натуральный ключ сортировки кода счёта: числовые сегменты — по значению (`01 < 9 < 58 < 58.03 < 60 < 60.2 <
60.10`), буквенные — по тексту после числовых. Одно определение порядка счетов для чек-листа и свода (№48)."""
parts = (part for part in _ACCOUNT_SPLIT_RE.split(code) if part)
return tuple((0, int(part), "") if part.isdigit() else (1, 0, part) for part in parts)
def _sums(row: OsvRow) -> tuple[Decimal | None, ...]: def _sums(row: OsvRow) -> tuple[Decimal | None, ...]:
return ( return (
row.opening_debit, row.opening_debit,

View File

@ -8,11 +8,11 @@
import datetime import datetime
import uuid import uuid
from decimal import Decimal from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from src.domain.checklist import CellStatus from src.domain.checklist import CellStatus
from src.domain.consolidated_osv import CounterpartyType # признак контрагента §3: одно определение (T2.8)
from src.models.enums import ( from src.models.enums import (
ConsolidationApproach, ConsolidationApproach,
ConsolidationScenario, ConsolidationScenario,
@ -27,8 +27,6 @@ YEAR_MAX = 2100
DEFAULT_OSV_LIMIT = 500 DEFAULT_OSV_LIMIT = 500
MAX_OSV_LIMIT = 2000 MAX_OSV_LIMIT = 2000
MAX_TAKEOVER_REASON = 1000 MAX_TAKEOVER_REASON = 1000
# признак контрагента относительно версии (§3): ГСК — в периметре, ГК — в группе, но вне периметра, иначе внешний
CounterpartyType = Literal["GSK", "GK", "EXTERNAL"]
class RefOut(BaseModel): class RefOut(BaseModel):

View File

@ -27,6 +27,7 @@ from sqlalchemy import delete, select
from sqlalchemy.engine import CursorResult from sqlalchemy.engine import CursorResult
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.consolidation_periods import QUARTER_ORDER, counts_as_cumulative
from src.domain.policy import IN_WORK_STATUSES from src.domain.policy import IN_WORK_STATUSES
from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationMember, ConsolidationVersion from src.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationMember, ConsolidationVersion
from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus, PeriodQuarter from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus, PeriodQuarter
@ -45,7 +46,6 @@ __all__ = [
#: Статусы, из которых подтверждение привязанного документа переводит версию в `DATA_CORRECTION` (D-6). #: Статусы, из которых подтверждение привязанного документа переводит версию в `DATA_CORRECTION` (D-6).
MOVES_TO_CORRECTION = frozenset({ConsolidationStatus.INITIATED, ConsolidationStatus.DOCUMENTS_PROCESSING}) MOVES_TO_CORRECTION = frozenset({ConsolidationStatus.INITIATED, ConsolidationStatus.DOCUMENTS_PROCESSING})
_QUARTER_ORDER = {quarter: position for position, quarter in enumerate(PeriodQuarter)}
BindingKey = tuple[uuid.UUID, DocumentType, str | None, PeriodQuarter | None, bool] BindingKey = tuple[uuid.UUID, DocumentType, str | None, PeriodQuarter | None, bool]
@ -56,8 +56,8 @@ def _rowcount(result: Any) -> int:
def _cumulative(document: Document) -> bool: def _cumulative(document: Document) -> bool:
"""Нарастающий ли документ: `is_cumulative` true или NULL (ББ/ОФР всегда за период с начала года, решение №10).""" """Нарастающий ли документ (R-1, решение №10): `consolidation_periods.counts_as_cumulative` — NULL нарастающий."""
return document.is_cumulative is not False return counts_as_cumulative(document.is_cumulative)
def _key(document: Document) -> BindingKey: def _key(document: Document) -> BindingKey:
@ -92,7 +92,7 @@ def fits(
return False return False
if _cumulative(document): if _cumulative(document):
return document.period_quarter == consolidation.quarter return document.period_quarter == consolidation.quarter
return _QUARTER_ORDER[document.period_quarter] <= _QUARTER_ORDER[consolidation.quarter] return QUARTER_ORDER.index(document.period_quarter) <= QUARTER_ORDER.index(consolidation.quarter)
async def _member_ids(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]: async def _member_ids(session: AsyncSession, version_id: uuid.UUID) -> set[uuid.UUID]:

View File

@ -0,0 +1,499 @@
"""Сводная ОСВ версии (Срез 4, задача 4.1; spec consolidated-osv; D-1, D-2, D-3, D-16): чистая функция без БД."""
import dataclasses
import random
import uuid
from collections.abc import Sequence
from decimal import Decimal
import pytest
from src.domain.consolidated_osv import (
Consolidated,
ConsolidatedRow,
CounterpartyType,
Entity,
GapItem,
OsvFilter,
SourceDocument,
SourceRow,
apply_filter,
build,
classify,
summary,
)
from src.domain.osv_mapping import Resolution
from src.models.enums import CounterpartyMatchKind, PeriodQuarter
Q1, Q2, Q3 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3
INN, NAME, ALIAS, MANUAL, PROPAGATED = (
CounterpartyMatchKind.INN,
CounterpartyMatchKind.NAME,
CounterpartyMatchKind.ALIAS,
CounterpartyMatchKind.MANUAL,
CounterpartyMatchKind.PROPAGATED,
)
GSK, GK, EXTERNAL = CounterpartyType.GSK, CounterpartyType.GK, CounterpartyType.EXTERNAL
ZERO = Decimal("0")
GROUP = uuid.uuid4()
RB = Entity(uuid.uuid4(), "АО Русский бисквит", "3528052718", GROUP)
SCH = Entity(uuid.uuid4(), "ООО Пекарня Счастливочная", "3528000001", GROUP)
CHKF = Entity(uuid.uuid4(), "ООО ЧКФ", "3528000002", GROUP) # в ГК, не в периметре версии
OUTSIDER = Entity(uuid.uuid4(), "ООО Внешний поставщик", "7700000003", uuid.uuid4())
LONE = Entity(uuid.uuid4(), "ИП Монозаёмщик", "770000000004", None)
MEMBERS = {RB.id: RB, SCH.id: SCH}
ENTITIES = {entity.id: entity for entity in (RB, SCH, CHKF, OUTSIDER, LONE)}
class _Resolver:
"""Справочник для дорезолва: ответы по (ИНН, имя), журнал вызовов."""
def __init__(self, answers: dict[tuple[str | None, str | None], Resolution] | None = None) -> None:
self.answers = answers or {}
self.calls: list[tuple[str | None, str | None]] = []
def resolve(self, inn: str | None, name: str | None) -> Resolution | None:
self.calls.append((inn, name))
return self.answers.get((inn, name))
def _doc(
entity: Entity = RB, account: str = "60", quarter: PeriodQuarter = Q3, *, cumulative: bool | None = False
) -> SourceDocument:
return SourceDocument(uuid.uuid4(), entity.id, account, quarter, cumulative)
_AMOUNT_FIELDS = {
"od": "opening_debit",
"oc": "opening_credit",
"td": "turnover_debit",
"tc": "turnover_credit",
"cd": "closing_debit",
"cc": "closing_credit",
}
def _amounts(**short: str | None) -> dict[str, Decimal | None]:
"""Суммы строки по коротким именам (`od`, `oc`, `td`, `tc`, `cd`, `cc`): не заданные — ноль, `None` — пусто."""
assert set(short) <= set(_AMOUNT_FIELDS), set(short) - set(_AMOUNT_FIELDS)
return {
field: None if (value := short.get(key, "0")) is None else Decimal(value)
for key, field in _AMOUNT_FIELDS.items()
}
def _row( # noqa: PLR0913 - тестовая фабрика строки osv_entry
document: SourceDocument,
name: str | None = "А",
*,
inn: str | None = None,
row_number: int | None = 1,
account_code: str | None = None,
counterparty_id: uuid.UUID | None = None,
match_kind: CounterpartyMatchKind | None = None,
**amounts: str | None,
) -> SourceRow:
code = account_code if account_code is not None else document.osv_account_code
return SourceRow(
id=uuid.uuid4(),
document_id=document.id,
row_number=row_number,
account_code=code,
account_root=(code or "").split(".")[0] or None,
counterparty_name=name,
counterparty_inn=inn,
counterparty_id=counterparty_id,
match_kind=match_kind,
**_amounts(**amounts),
)
def _build(
documents: Sequence[SourceDocument],
rows: Sequence[SourceRow],
*,
quarter: PeriodQuarter = Q3,
resolver: _Resolver | None = None,
) -> Consolidated:
return build(
year=2024,
quarter=quarter,
group_id=GROUP,
members=MEMBERS,
entities=ENTITIES,
documents=documents,
rows=rows,
resolver=resolver or _Resolver(),
)
def _sums(row: ConsolidatedRow) -> tuple[Decimal | None, ...]:
return (
row.opening_debit,
row.opening_credit,
row.turnover_debit,
row.turnover_credit,
row.closing_debit,
row.closing_credit,
)
# --------------------------------------------------------------------------- приведение периодов
def test_three_quarters_are_glued_into_cumulative_row() -> None:
"""Сценарий «Три квартала»: opening из Q1, turnover = Σ, closing из Q3; entry_ids — три источника."""
q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3)
r1 = _row(q1, od="100", td="200", cd="300")
r2 = _row(q2, od="300", td="150", cd="450")
r3 = _row(q3, od="450", td="50", cd="500")
result = _build([q3, q1, q2], [r3, r2, r1])
assert len(result.rows) == 1
row = result.rows[0]
assert (row.opening_debit, row.turnover_debit, row.closing_debit) == (Decimal(100), Decimal(400), Decimal(500))
assert (row.opening_credit, row.turnover_credit, row.closing_credit) == (ZERO, ZERO, ZERO)
assert row.period == "2024 Q3"
assert row.is_cumulative is False
assert row.entry_ids == (r1.id, r2.id, r3.id)
assert (row.legal_entity_id, row.legal_entity_name, row.legal_entity_inn) == (RB.id, RB.name, RB.inn)
assert (row.account_code, row.account_root, row.counterparty_name) == ("60", "60", "А")
assert result.gaps == ()
assert result.documents_total == 3 # noqa: PLR2004
def test_counterparty_only_in_middle_quarter() -> None:
"""Сценарий «Контрагент только во втором квартале»: opening/closing — нули, turnover — из Q2."""
q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3)
only = _row(q2, "Б", row_number=4, td="70", cd="70")
result = _build([q1, q2, q3], [_row(q1), only, _row(q3)])
row = next(r for r in result.rows if r.counterparty_name == "Б")
assert _sums(row) == (ZERO, ZERO, Decimal(70), ZERO, ZERO, ZERO)
assert row.entry_ids == (only.id,)
assert row.row_number == 4 # noqa: PLR2004 - ключа нет в последнем квартале: номер из последнего, где он есть
def test_gap_skips_pair_and_lists_missing_quarters() -> None:
"""Сценарий «Пробел»: Q1 и Q3 есть, Q2 нет → строк по 60 нет, пробел с `Q2`; соседний счёт строится."""
q1, q3 = _doc(quarter=Q1), _doc(quarter=Q3)
cumulative_62 = _doc(account="62", cumulative=True)
result = _build([q1, q3, cumulative_62], [_row(q1), _row(q3), _row(cumulative_62, "В")])
assert [r.account_code for r in result.rows] == ["62"]
assert result.gaps == (GapItem(RB.id, RB.name, "60", (Q2,)),)
assert result.documents_total == 3 # noqa: PLR2004
def test_gap_of_cumulative_from_earlier_quarter() -> None:
"""Review Focus 2 (защитно): нарастающий Q2 при версии Q3 — не источник; пара — пробел Q1–Q3."""
stale = _doc(account="62", quarter=Q2, cumulative=True)
result = _build([stale], [_row(stale)])
assert result.rows == ()
assert result.gaps == (GapItem(RB.id, RB.name, "62", (Q1, Q2, Q3)),)
def test_cumulative_next_to_quarterly_rows_as_is() -> None:
"""Сценарий «Нарастающий рядом с квартальными»: строки нарастающего один в один (суммы `None` — как есть)."""
quarterly = [_doc(account="62", quarter=q) for q in (Q1, Q2, Q3)]
cumulative = _doc(account="62", cumulative=True)
source = _row(cumulative, row_number=9, od="1.50", oc=None, td="2", tc=None, cd="3.50", cc=None)
result = _build([*quarterly, cumulative], [*(_row(d, td="999") for d in quarterly), source])
assert len(result.rows) == 1
row = result.rows[0]
assert row.is_cumulative is True
assert row.entry_ids == (source.id,)
assert row.row_number == 9 # noqa: PLR2004
assert _sums(row) == (Decimal("1.50"), None, Decimal(2), None, Decimal("3.50"), None)
assert result.gaps == ()
def test_cumulative_document_keeps_duplicate_keys_separate() -> None:
"""Решение №1: дубли ключа в нарастающем документе не суммируются — строки свода = строки файла (§3 п. 6)."""
cumulative = _doc(cumulative=True)
first, second = _row(cumulative, row_number=1, td="10"), _row(cumulative, row_number=2, td="20")
result = _build([cumulative], [second, first])
assert [r.entry_ids for r in result.rows] == [(first.id,), (second.id,)]
def test_q1_version_quarterly_document_is_single_quarter_glue() -> None:
q1 = _doc(quarter=Q1)
source = _row(q1, od="5", td="6", cd="11")
result = _build([q1], [source], quarter=Q1)
assert len(result.rows) == 1
assert result.rows[0].period == "2024 Q1"
assert result.rows[0].is_cumulative is False
assert _sums(result.rows[0]) == (Decimal(5), ZERO, Decimal(6), ZERO, Decimal(11), ZERO)
def test_duplicate_key_within_quarter_is_summed() -> None:
"""Решение №1: две строки одного ключа в квартале суммируются, `entry_ids` — обе; row_number — минимальный."""
q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3)
a = _row(q1, row_number=3, od="10", td="1")
b = _row(q1, row_number=8, od="20", td="2")
c = _row(q2, td="4")
d = _row(q3, row_number=7, td="8", cd="100")
e = _row(q3, row_number=5, td="16", cd="200")
result = _build([q1, q2, q3], [b, a, c, d, e])
assert len(result.rows) == 1
row = result.rows[0]
assert (row.opening_debit, row.turnover_debit, row.closing_debit) == (Decimal(30), Decimal(31), Decimal(300))
assert set(row.entry_ids) == {a.id, b.id, c.id, d.id, e.id}
assert row.entry_ids == (a.id, b.id, c.id, e.id, d.id) # по кварталам, внутри — по номеру строки
assert row.row_number == 5 # noqa: PLR2004 - минимум по последнему кварталу, не по Q1
def test_none_amounts_count_as_zero_when_glued() -> None:
q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3)
rows = [
_row(q1, od=None, oc="7", td=None, tc="1", cd=None, cc=None),
_row(q2, td="2", tc=None),
_row(q3, td=None, tc=None, cd="2", cc=None),
]
row = _build([q1, q2, q3], rows).rows[0]
assert _sums(row) == (ZERO, Decimal(7), Decimal(2), Decimal(1), Decimal(2), ZERO)
assert all(isinstance(value, Decimal) for value in _sums(row))
def test_glue_key_strips_name_and_inn_whitespace() -> None:
"""Ключ — (счёт строки, имя.strip(), ИНН без пробелов); «как в файле» — из последнего квартала."""
q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3)
rows = [
_row(q1, " ООО Ромашка ", inn="7701 000 001", td="1"),
_row(q2, "ООО Ромашка", inn=" 7701000001", td="2"),
_row(q3, "ООО Ромашка ", inn="7701000001 ", td="4"),
]
result = _build([q1, q2, q3], rows)
assert len(result.rows) == 1
assert result.rows[0].turnover_debit == Decimal(7)
assert (result.rows[0].counterparty_name, result.rows[0].counterparty_inn) == ("ООО Ромашка ", "7701000001 ")
def test_glue_key_separates_inn_and_subaccount() -> None:
q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3)
rows = [
*(_row(d, "А", inn="7701000001") for d in (q1, q2, q3)),
*(_row(d, "А", inn="7701000002") for d in (q1, q2, q3)),
*(_row(d, "А", inn="7701000001", account_code="60.02") for d in (q1, q2, q3)),
*(_row(d, "А", inn=None) for d in (q1, q2, q3)),
]
result = _build([q1, q2, q3], rows)
assert len(result.rows) == 4 # noqa: PLR2004
assert all(len(r.entry_ids) == 3 for r in result.rows) # noqa: PLR2004
def test_rows_of_unknown_documents_and_non_members_are_ignored() -> None:
q3 = _doc(cumulative=True)
foreign = _doc(CHKF, cumulative=True) # защитно: документ не члена периметра в свод не попадает
orphan = _row(_doc(cumulative=True)) # строка документа, которого нет во входе
result = _build([q3, foreign], [_row(q3), _row(foreign), orphan])
assert [r.legal_entity_id for r in result.rows] == [RB.id]
assert result.gaps == ()
def test_empty_input() -> None:
assert _build([], []) == Consolidated(rows=(), gaps=(), documents_total=0)
assert summary(_build([], [])) == (0, 0, 0)
# --------------------------------------------------------------------------- контрагент: id, признак, дорезолв
def test_counterparty_id_from_last_quarter_with_warning_on_divergence() -> None:
"""Решение №8: расхождение `counterparty_id` между кварталами — значение последнего квартала + `warnings`."""
q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3)
early = _row(q1, counterparty_id=CHKF.id, match_kind=INN)
unmapped = _row(q2)
late = _row(q3, counterparty_id=SCH.id, match_kind=MANUAL)
resolver = _Resolver()
row = _build([q1, q2, q3], [early, unmapped, late], resolver=resolver).rows[0]
assert (row.counterparty_id, row.match_kind, row.match_stored) == (SCH.id, MANUAL, True)
assert (row.counterparty_type, row.counterparty_entity_name) == (GSK, SCH.name)
assert len(row.warnings) == 1
assert str(early.id) in row.warnings[0]
assert str(CHKF.id) in row.warnings[0]
assert resolver.calls == []
def test_counterparty_id_from_earlier_quarter_when_last_is_empty() -> None:
q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3)
rows = [_row(q1, counterparty_id=SCH.id, match_kind=NAME), _row(q2), _row(q3)]
row = _build([q1, q2, q3], rows).rows[0]
assert (row.counterparty_id, row.match_kind, row.match_stored, row.warnings) == (SCH.id, NAME, True, ())
def test_same_counterparty_in_all_quarters_has_no_warning() -> None:
q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3)
rows = [_row(d, counterparty_id=SCH.id, match_kind=PROPAGATED) for d in (q1, q2, q3)]
row = _build([q1, q2, q3], rows).rows[0]
assert (row.counterparty_id, row.match_kind, row.warnings) == (SCH.id, PROPAGATED, ())
def test_counterparty_types_on_rb_fixture_shape() -> None:
"""Сценарий «Признаки на фикстурах РБ»: Счастливочная → РБ по ИНН — GSK; РБ → «Пекарня Счастливочная ООО» без
ИНН не сопоставлена — EXTERNAL; РБ → ЧКФ (в ГК, не в ГСК) — GK."""
sch_60 = _doc(SCH, cumulative=True)
rb_60 = _doc(RB, cumulative=True)
to_rb = _row(sch_60, "Русский бисквит", inn="3528052718", counterparty_id=RB.id, match_kind=INN)
to_sch = _row(rb_60, "Пекарня Счастливочная ООО", row_number=1)
to_chkf = _row(rb_60, "ЧКФ", row_number=2, counterparty_id=CHKF.id, match_kind=NAME)
result = _build([sch_60, rb_60], [to_rb, to_sch, to_chkf])
by_entry = {r.entry_ids[0]: r for r in result.rows}
assert (by_entry[to_rb.id].counterparty_type, by_entry[to_rb.id].match_kind) == (GSK, INN)
assert by_entry[to_rb.id].match_stored is True
assert by_entry[to_sch.id].counterparty_type == EXTERNAL
assert (by_entry[to_sch.id].counterparty_id, by_entry[to_sch.id].match_kind) == (None, None)
assert by_entry[to_sch.id].match_stored is False
assert by_entry[to_sch.id].counterparty_entity_name is None
assert by_entry[to_chkf.id].counterparty_type == GK
assert by_entry[to_chkf.id].counterparty_entity_name == CHKF.name
def test_classify_counterparty_type() -> None:
def kind(counterparty_id: uuid.UUID | None) -> CounterpartyType:
return classify(counterparty_id, members=MEMBERS, entities=ENTITIES, group_id=GROUP)
assert (kind(SCH.id), kind(RB.id), kind(CHKF.id)) == (GSK, GSK, GK)
assert (kind(OUTSIDER.id), kind(LONE.id), kind(None), kind(uuid.uuid4())) == (EXTERNAL,) * 4
def test_resolve_on_read_is_not_stored() -> None:
"""Сценарий «Дорезолв на чтение»: пустой `counterparty_id` резолвится на лету, `match_stored = false`,
входная строка не меняется."""
cumulative = _doc(cumulative=True)
source = _row(cumulative, "Пекарня Счастливочная ООО", inn=" ")
resolver = _Resolver({(" ", "Пекарня Счастливочная ООО"): Resolution(SCH.id, ALIAS)})
row = _build([cumulative], [source], resolver=resolver).rows[0]
assert (row.counterparty_id, row.match_kind, row.match_stored) == (SCH.id, ALIAS, False)
assert (row.counterparty_type, row.counterparty_entity_name) == (GSK, SCH.name)
assert resolver.calls == [(" ", "Пекарня Счастливочная ООО")]
assert source.counterparty_id is None
assert source.match_kind is None
def test_resolve_on_read_for_glued_row_uses_last_quarter_values() -> None:
q1, q2, q3 = _doc(quarter=Q1), _doc(quarter=Q2), _doc(quarter=Q3)
rows = [_row(q1, " ЧКФ", inn="3528 000002"), _row(q2, "ЧКФ", inn="3528000002"), _row(q3, "ЧКФ ", inn="3528000002")]
resolver = _Resolver({("3528000002", "ЧКФ "): Resolution(CHKF.id, INN)})
result = _build([q1, q2, q3], rows, resolver=resolver)
assert len(result.rows) == 1
row = result.rows[0]
assert resolver.calls == [("3528000002", "ЧКФ ")]
assert (row.counterparty_id, row.match_kind, row.match_stored, row.counterparty_type) == (CHKF.id, INN, False, GK)
def test_rows_are_frozen() -> None:
row = _build([cumulative := _doc(cumulative=True)], [_row(cumulative)]).rows[0]
with pytest.raises(dataclasses.FrozenInstanceError):
row.counterparty_id = SCH.id # type: ignore[misc]
# --------------------------------------------------------------------------- порядок
def test_rows_order_entity_name_account_row_number_entry_id() -> None:
"""Порядок: имя ЮЛ, счёт (натурально: 9 < 60 < 60.2 < 60.10), номер строки (пустой — в конце), первый entry_id."""
rb_9, rb_60, sch_60 = _doc(RB, "9", cumulative=True), _doc(RB, "60", cumulative=True), _doc(SCH, cumulative=True)
rows = [
_row(sch_60, "С1", row_number=1),
_row(rb_60, "Р3", row_number=None, account_code="60.10"),
_row(rb_60, "Р2", row_number=2, account_code="60.10"),
_row(rb_60, "Р1", row_number=5, account_code="60.2"),
_row(rb_60, "Р0", row_number=7, account_code="60"),
_row(rb_9, "Р9", row_number=100),
]
tied = sorted(
[_row(rb_60, "Т1", row_number=3, account_code="60.10"), _row(rb_60, "Т2", row_number=3, account_code="60.10")],
key=lambda r: r.id,
)
result = _build([rb_9, rb_60, sch_60], [*rows, *reversed(tied)])
names = [r.counterparty_name for r in result.rows]
assert names == ["Р9", "Р0", "Р1", "Р2", tied[0].counterparty_name, tied[1].counterparty_name, "Р3", "С1"]
def test_build_is_deterministic_for_shuffled_input() -> None:
documents = [
_doc(entity, account, quarter) for entity in (RB, SCH) for account in ("60", "62") for quarter in (Q1, Q2, Q3)
]
rows = [_row(d, name, row_number=n, td=str(n)) for d in documents for n, name in enumerate(("А", "Б", "В"))]
expected = _build(documents, rows)
shuffled_docs, shuffled_rows = documents[:], rows[:]
random.Random(7).shuffle(shuffled_docs)
random.Random(11).shuffle(shuffled_rows)
assert _build(shuffled_docs, shuffled_rows) == expected
assert len(expected.rows) == 12 # noqa: PLR2004 - 2 ЮЛ × 2 счёта × 3 контрагента
def test_gaps_are_ordered_by_entity_name_and_account() -> None:
documents = [_doc(SCH, "62", Q1), _doc(RB, "62", Q1), _doc(RB, "60", Q2)]
result = _build(documents, [])
assert [(g.legal_entity_name, g.account_code, g.missing_quarters) for g in result.gaps] == [
(RB.name, "60", (Q1, Q3)),
(RB.name, "62", (Q2, Q3)),
(SCH.name, "62", (Q2, Q3)),
]
# --------------------------------------------------------------------------- фильтры и сводка
def _filter_fixture() -> tuple[ConsolidatedRow, ...]:
rb, sch = _doc(RB, "60", cumulative=True), _doc(SCH, "62", cumulative=True)
rows = [
_row(rb, "ООО Ромашка", inn="7701000001", row_number=1), # EXTERNAL, не сопоставлена
_row(rb, "Пекарня Счастливочная ООО", row_number=2), # ALIAS дорезолвом
_row(rb, "ЧКФ", row_number=3, account_code="60.01", counterparty_id=CHKF.id, match_kind=NAME), # GK, NAME
_row(sch, "Русский бисквит", inn="3528052718", counterparty_id=RB.id, match_kind=INN), # GSK, INN
_row(sch, "Внешний", row_number=2, counterparty_id=OUTSIDER.id, match_kind=MANUAL), # EXTERNAL
]
resolver = _Resolver({(None, "Пекарня Счастливочная ООО"): Resolution(SCH.id, ALIAS)})
return _build([rb, sch], rows, resolver=resolver).rows
def _names(rows: Sequence[ConsolidatedRow]) -> list[str | None]:
return [r.counterparty_name for r in rows]
def test_empty_filter_returns_all_rows() -> None:
rows = _filter_fixture()
assert apply_filter(rows, OsvFilter()) == list(rows)
@pytest.mark.parametrize(
("osv_filter", "expected"),
[
(OsvFilter(legal_entity_id=SCH.id), ["Русский бисквит", "Внешний"]),
(OsvFilter(account_root="62"), ["Русский бисквит", "Внешний"]),
(OsvFilter(account_root="6"), []),
(OsvFilter(counterparty_type=GSK), ["Пекарня Счастливочная ООО", "Русский бисквит"]),
(OsvFilter(counterparty_type=GK), ["ЧКФ"]),
(OsvFilter(counterparty_type=EXTERNAL), ["ООО Ромашка", "Внешний"]),
(OsvFilter(unmapped=True), ["ООО Ромашка"]),
(OsvFilter(unmapped=False), ["Пекарня Счастливочная ООО", "ЧКФ", "Русский бисквит", "Внешний"]),
(OsvFilter(match_kinds=frozenset({NAME, ALIAS})), ["Пекарня Счастливочная ООО", "ЧКФ"]),
(OsvFilter(match_kinds=frozenset({MANUAL})), ["Внешний"]),
(OsvFilter(q="рОмАш"), ["ООО Ромашка"]),
(OsvFilter(q=" бисквит "), ["Русский бисквит"]),
(OsvFilter(q="7701"), ["ООО Ромашка"]),
(OsvFilter(q="1000001"), []), # ИНН — только префикс
(OsvFilter(q=" "), ["ООО Ромашка", "Пекарня Счастливочная ООО", "ЧКФ", "Русский бисквит", "Внешний"]),
(OsvFilter(legal_entity_id=RB.id, counterparty_type=EXTERNAL), ["ООО Ромашка"]),
(OsvFilter(legal_entity_id=RB.id, match_kinds=frozenset({ALIAS}), q="пекарня"), ["Пекарня Счастливочная ООО"]),
(OsvFilter(account_root="60", unmapped=True, q="ЧКФ"), []),
],
)
def test_apply_filter(osv_filter: OsvFilter, expected: list[str]) -> None:
assert _names(apply_filter(_filter_fixture(), osv_filter)) == expected
def test_summary_counts_and_matches_filters() -> None:
"""Сценарии «Только несопоставленные» и «Фильтр по способу»: total фильтра = счётчик сводки."""
rows = _filter_fixture()
result = Consolidated(rows=rows, gaps=(), documents_total=2)
rows_total, unmapped_total, auto_matched_total = summary(result)
assert (rows_total, unmapped_total, auto_matched_total) == (5, 1, 2)
assert len(apply_filter(rows, OsvFilter(unmapped=True))) == unmapped_total
assert len(apply_filter(rows, OsvFilter(match_kinds=frozenset({NAME, ALIAS})))) == auto_matched_total
assert all(r.counterparty_id is None for r in apply_filter(rows, OsvFilter(unmapped=True)))
def test_counterparty_type_is_str_enum() -> None:
assert [t.value for t in CounterpartyType] == ["GSK", "GK", "EXTERNAL"]

View File

@ -1,12 +1,24 @@
"""Отчётная дата консолидации по году и кварталу (spec consolidations-api «Создание консолидации»; OQ-40).""" """Период консолидации (Срез 4): отчётная дата (OQ-40) и выбор источников ОСВ на пару (ЮЛ, счёт) — D-2, §2."""
import datetime import datetime
import uuid
from dataclasses import dataclass
import pytest import pytest
from src.domain.consolidation_periods import reporting_date from src.domain.consolidation_periods import (
QUARTER_ORDER,
Gap,
Sources,
counts_as_cumulative,
quarters_through,
reporting_date,
select_sources,
)
from src.models.enums import PeriodQuarter from src.models.enums import PeriodQuarter
Q1, Q2, Q3, Q4 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4
@pytest.mark.parametrize( @pytest.mark.parametrize(
("year", "quarter", "expected"), ("year", "quarter", "expected"),
@ -20,3 +32,86 @@ from src.models.enums import PeriodQuarter
) )
def test_reporting_date_is_first_day_after_quarter(year: int, quarter: PeriodQuarter, expected: datetime.date) -> None: def test_reporting_date_is_first_day_after_quarter(year: int, quarter: PeriodQuarter, expected: datetime.date) -> None:
assert reporting_date(year, quarter) == expected assert reporting_date(year, quarter) == expected
@dataclass(frozen=True)
class _Doc:
"""Документ для `select_sources`: структурно подходит под `DocLike` (как `Document` и `SourceDocument`)."""
period_quarter: PeriodQuarter | None
is_cumulative: bool | None
id: uuid.UUID = uuid.UUID(int=0)
def _doc(quarter: PeriodQuarter | None, cumulative: bool | None = False) -> _Doc:
return _Doc(quarter, cumulative, uuid.uuid4())
def test_quarter_order_and_quarters_through() -> None:
assert QUARTER_ORDER == (Q1, Q2, Q3, Q4)
assert quarters_through(Q1) == (Q1,)
assert quarters_through(Q3) == (Q1, Q2, Q3)
assert quarters_through(Q4) == QUARTER_ORDER
def test_counts_as_cumulative_null_is_cumulative() -> None:
"""R-1, решение №10: NULL (ББ/ОФР) — нарастающий; только `False` — квартальный."""
assert counts_as_cumulative(True) is True
assert counts_as_cumulative(None) is True
assert counts_as_cumulative(False) is False
def test_cumulative_of_version_quarter_is_single_source() -> None:
cumulative = _doc(Q3, True)
assert select_sources(Q3, [cumulative]) == Sources(documents=(cumulative,), cumulative=True)
def test_null_cumulative_flag_of_version_quarter_counts_as_cumulative() -> None:
document = _doc(Q3, None)
assert select_sources(Q3, [document]) == Sources(documents=(document,), cumulative=True)
def test_quarterly_q1_to_q3_are_sources_in_quarter_order() -> None:
q1, q2, q3 = _doc(Q1), _doc(Q2), _doc(Q3)
assert select_sources(Q3, [q3, q1, q2]) == Sources(documents=(q1, q2, q3), cumulative=False)
def test_missing_middle_quarter_is_gap() -> None:
assert select_sources(Q3, [_doc(Q1), _doc(Q3)]) == Gap(missing_quarters=(Q2,))
def test_q1_version_quarterly_q1_is_enough() -> None:
"""Для версии Q1 квартальный Q1 равнозначен нарастающему: источник есть, пробела нет."""
q1 = _doc(Q1)
assert select_sources(Q1, [q1]) == Sources(documents=(q1,), cumulative=False)
def test_cumulative_beats_quarterly_of_same_pair() -> None:
"""Review Focus 2: квартальные Q1–Q3 и нарастающий Q3 — источник один, нарастающий."""
cumulative = _doc(Q3, True)
assert select_sources(Q3, [_doc(Q1), _doc(Q2), _doc(Q3), cumulative]) == Sources((cumulative,), cumulative=True)
def test_cumulative_beats_quarterly_with_gap() -> None:
cumulative = _doc(Q3, True)
assert select_sources(Q3, [_doc(Q1), cumulative]) == Sources((cumulative,), cumulative=True)
def test_cumulative_of_earlier_quarter_is_ignored() -> None:
"""Review Focus 2 (защитно): нарастающий Q2 при версии Q3 не привязывается, но и функция его не берёт."""
assert select_sources(Q3, [_doc(Q2, True)]) == Gap(missing_quarters=(Q1, Q2, Q3))
def test_cumulative_of_earlier_quarter_does_not_fill_gap_of_quarterly() -> None:
assert select_sources(Q3, [_doc(Q1), _doc(Q2, True), _doc(Q3)]) == Gap(missing_quarters=(Q2,))
def test_quarterly_after_version_and_without_quarter_are_ignored() -> None:
q1, q2, q3 = _doc(Q1), _doc(Q2), _doc(Q3)
assert select_sources(Q3, [q1, q2, q3, _doc(Q4), _doc(None), _doc(None, True)]) == Sources((q1, q2, q3), False)
assert select_sources(Q2, [_doc(Q3)]) == Gap(missing_quarters=(Q1, Q2))
def test_no_documents_is_gap_of_all_quarters() -> None:
assert select_sources(Q3, []) == Gap(missing_quarters=(Q1, Q2, Q3))
assert select_sources(Q1, []) == Gap(missing_quarters=(Q1,))

View File

@ -8,7 +8,9 @@ import pytest
from pydantic import ValidationError from pydantic import ValidationError
from src.domain.checklist import CellStatus, EntityFilesState from src.domain.checklist import CellStatus, EntityFilesState
from src.domain.consolidated_osv import CounterpartyType as DomainCounterpartyType
from src.models.enums import CounterpartyMatchKind, FileStatus, PeriodQuarter from src.models.enums import CounterpartyMatchKind, FileStatus, PeriodQuarter
from src.schemas import consolidations as schemas
from src.schemas.consolidations import ( from src.schemas.consolidations import (
ChecklistCellOut, ChecklistCellOut,
ConsolidatedRowOut, ConsolidatedRowOut,
@ -89,3 +91,11 @@ def test_checklist_cell_status_is_the_domain_enum() -> None:
assert cell.status is status assert cell.status is status
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
ChecklistCellOut(status="UNKNOWN", missing_quarters=[], document_ids=[]) # type: ignore[arg-type] ChecklistCellOut(status="UNKNOWN", missing_quarters=[], document_ids=[]) # type: ignore[arg-type]
def test_counterparty_type_is_the_domain_enum() -> None:
"""Признак контрагента в схеме — `domain.consolidated_osv.CounterpartyType` (одно определение, T2.8)."""
assert schemas.CounterpartyType is DomainCounterpartyType
assert OsvFilters(counterparty_type="GK").counterparty_type is DomainCounterpartyType.GK
with pytest.raises(ValidationError):
OsvFilters(counterparty_type="OTHER") # type: ignore[arg-type]

View File

@ -187,9 +187,9 @@
- `consolidated_osv.py`: входные dataclass'ы `SourceRow` (из `OsvEntry`: `id`, `document_id`, `row_number`, `account_code`, `account_root`, `counterparty_name`, `counterparty_inn`, `counterparty_id`, `match_kind`, шесть сумм), `SourceDocument` (`id`, `legal_entity_id`, `osv_account_code`, `period_quarter`, `is_cumulative`), `Entity` (`id`, `name`, `inn`, `company_group_id`); `class CounterpartyType(StrEnum): GSK, GK, EXTERNAL`; `@dataclass(frozen=True) class ConsolidatedRow` (поля D-16 + `warnings: tuple[str, ...]`); `@dataclass(frozen=True) class GapItem(legal_entity_id, legal_entity_name, account_code, missing_quarters)`; `@dataclass(frozen=True) class Consolidated: rows: tuple[ConsolidatedRow, ...]; gaps: tuple[GapItem, ...]; documents_total: int`; `class Resolver(Protocol): def resolve(self, inn, name) -> Resolution | None`; `def build(*, 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`; `@dataclass(frozen=True) class OsvFilter(legal_entity_id, account_root, counterparty_type, unmapped, match_kinds: frozenset, q)`; `def apply_filter(rows, f) -> list[ConsolidatedRow]`; `def summary(c: Consolidated) -> tuple[int rows_total, int unmapped_total, int auto_matched_total]`. - `consolidated_osv.py`: входные dataclass'ы `SourceRow` (из `OsvEntry`: `id`, `document_id`, `row_number`, `account_code`, `account_root`, `counterparty_name`, `counterparty_inn`, `counterparty_id`, `match_kind`, шесть сумм), `SourceDocument` (`id`, `legal_entity_id`, `osv_account_code`, `period_quarter`, `is_cumulative`), `Entity` (`id`, `name`, `inn`, `company_group_id`); `class CounterpartyType(StrEnum): GSK, GK, EXTERNAL`; `@dataclass(frozen=True) class ConsolidatedRow` (поля D-16 + `warnings: tuple[str, ...]`); `@dataclass(frozen=True) class GapItem(legal_entity_id, legal_entity_name, account_code, missing_quarters)`; `@dataclass(frozen=True) class Consolidated: rows: tuple[ConsolidatedRow, ...]; gaps: tuple[GapItem, ...]; documents_total: int`; `class Resolver(Protocol): def resolve(self, inn, name) -> Resolution | None`; `def build(*, 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`; `@dataclass(frozen=True) class OsvFilter(legal_entity_id, account_root, counterparty_type, unmapped, match_kinds: frozenset, q)`; `def apply_filter(rows, f) -> list[ConsolidatedRow]`; `def summary(c: Consolidated) -> tuple[int rows_total, int unmapped_total, int auto_matched_total]`.
- Ключ склейки: `(account_code or "", (counterparty_name or "").strip(), normalize_inn(counterparty_inn or ""))`; `counterparty_id` из последнего квартала (D-3, #8), `warnings` при расхождении; `row_number` = min по последнему кварталу; нули — `Decimal("0")`; суммы `None` считаются нулями при сложении, но строка нарастающего документа отдаётся как есть. - Ключ склейки: `(account_code or "", (counterparty_name or "").strip(), normalize_inn(counterparty_inn or ""))`; `counterparty_id` из последнего квартала (D-3, #8), `warnings` при расхождении; `row_number` = min по последнему кварталу; нули — `Decimal("0")`; суммы `None` считаются нулями при сложении, но строка нарастающего документа отдаётся как есть.
- [ ] **Step 1: Тесты (без БД).** `select_sources`: нарастающий Q3, квартальные Q1–Q3, пробел Q2, версия Q1, нарастающий+квартальные, только нарастающий Q2 при версии Q3 (защитно: такой документ не привязывается, но функция обязана его игнорировать) → `Gap(Q1,Q2,Q3)`. `build`: сценарии спеки «Три квартала», «Контрагент только во втором квартале», «Пробел», «Нарастающий рядом с квартальными», дубль ключа внутри квартала (сумма, `entry_ids` оба), расхождение `counterparty_id` между кварталами (последний квартал + `warnings`), признаки `GSK`/`GK`/`EXTERNAL`/пустой, дорезолв с `match_stored = false`, порядок строк, `apply_filter` по каждому полю, `summary`. - [x] **Step 1: Тесты (без БД).** `select_sources`: нарастающий Q3, квартальные Q1–Q3, пробел Q2, версия Q1, нарастающий+квартальные, только нарастающий Q2 при версии Q3 (защитно: такой документ не привязывается, но функция обязана его игнорировать) → `Gap(Q1,Q2,Q3)`. `build`: сценарии спеки «Три квартала», «Контрагент только во втором квартале», «Пробел», «Нарастающий рядом с квартальными», дубль ключа внутри квартала (сумма, `entry_ids` оба), расхождение `counterparty_id` между кварталами (последний квартал + `warnings`), признаки `GSK`/`GK`/`EXTERNAL`/пустой, дорезолв с `match_stored = false`, порядок строк, `apply_filter` по каждому полю, `summary`.
- [ ] **Step 2–4:** падает → реализация → `make check` (mypy strict для `src.domain.*`), `pytest tests/test_domain_consolidated_osv.py tests/test_domain_consolidation_periods.py tests/test_domain_checklist.py -q` → PASS. - [x] **Step 2–4:** падает → реализация → `make check` (mypy strict для `src.domain.*`), `pytest tests/test_domain_consolidated_osv.py tests/test_domain_consolidation_periods.py tests/test_domain_checklist.py -q` → PASS.
- [ ] **Step 5: Commit** `feat(domain): сводная ОСВ — выбор источников, приведение периодов, пробелы, признак контрагента`. - [x] **Step 5: Commit** `feat(domain): сводная ОСВ — выбор источников, приведение периодов, пробелы, признак контрагента`.
--- ---

View File

@ -234,3 +234,10 @@ legal_entity_name, account_code, missing_quarters[]}}`. Фильтры `GET …/
| 47 | 2026-10-06 | `document_ids` ячейки — только привязанные документы (покрывающие или частично покрывающие пару); кандидаты `NEEDS_CHECK`/`RECOGNIZED` в `document_ids` не попадают, при статусе `NEEDS_CHECK`/`RECOGNIZED`/`PROCESSING`/`ERROR` `missing_quarters` и `document_ids` сохраняют частичное покрытие | спека и схема: «привязанные документы»; пробел и статус кандидата независимы | UI не получит ссылку на документ-кандидат из чек-листа (она есть в списке документов ЮЛ) | | 47 | 2026-10-06 | `document_ids` ячейки — только привязанные документы (покрывающие или частично покрывающие пару); кандидаты `NEEDS_CHECK`/`RECOGNIZED` в `document_ids` не попадают, при статусе `NEEDS_CHECK`/`RECOGNIZED`/`PROCESSING`/`ERROR` `missing_quarters` и `document_ids` сохраняют частичное покрытие | спека и схема: «привязанные документы»; пробел и статус кандидата независимы | UI не получит ссылку на документ-кандидат из чек-листа (она есть в списке документов ЮЛ) |
| 48 | 2026-10-06 | Порядок правил — `domain.checklist.order_rules` (BALANCE, INCOME, ОСВ по натуральному ключу кода: числовые части по значению, `58 < 58.03 < 60`; прочие типы — после ОСВ), `rules_for` отдаёт правила в порядке `id`; порядок ячеек = порядок `rules` в ответе | порядок — предметное правило, ему место в чистом домене, а не в SQL по строке (`'9' > '60'` в текстовой сортировке) | нулевая | | 48 | 2026-10-06 | Порядок правил — `domain.checklist.order_rules` (BALANCE, INCOME, ОСВ по натуральному ключу кода: числовые части по значению, `58 < 58.03 < 60`; прочие типы — после ОСВ), `rules_for` отдаёт правила в порядке `id`; порядок ячеек = порядок `rules` в ответе | порядок — предметное правило, ему место в чистом домене, а не в SQL по строке (`'9' > '60'` в текстовой сортировке) | нулевая |
| 49 | 2026-10-06 | Ячейка ББ/ОФР покрыта привязанным документом того же типа с `period_quarter = квартал версии` (признак нарастающего не проверяется: ББ/ОФР всегда нарастающие, решение №10); ЮЛ без записи в `files_state` считается без файлов | `fits` уже отсекает прочее; защита от пропуска записи в `files_state` без KeyError | нулевая | | 49 | 2026-10-06 | Ячейка ББ/ОФР покрыта привязанным документом того же типа с `period_quarter = квартал версии` (признак нарастающего не проверяется: ББ/ОФР всегда нарастающие, решение №10); ЮЛ без записи в `files_state` считается без файлов | `fits` уже отсекает прочее; защита от пропуска записи в `files_state` без KeyError | нулевая |
| 50 | 2026-10-06 | Склейка квартальных строк всегда даёт `Decimal`: сумма `None` и отсутствующая строка — ноль, в том числе в `opening_*` из Q1 и `closing_*` из Qn; «как есть» (с `None`) отдаются только строки нарастающего документа. Версия за Q1 с квартальным Q1 идёт путём склейки (`is_cumulative = false`, дубли ключа суммируются) | пустая ячейка ОСВ означает ноль; одно правило для всех склеенных строк; признак строки отражает тип документа | в склеенной строке пустая ячейка файла показывается как `0.00`, а не пусто |
| 51 | 2026-10-06 | «Как в файле» (`account_code`, `account_root`, `counterparty_name/inn`) и `row_number` склеенной строки — из последнего квартала, где ключ есть (минимальный номер строки в нём, пустой — в конце, затем `id`); если ключа нет в Qn (контрагент только в Q2) — из последнего квартала с ним | спека задаёт `row_number` по последнему кварталу, но не говорит про ключ, которого в нём нет; написание имени/ИНН внутри ключа может отличаться пробелами | порядок такой строки в своде и показанное написание имени |
| 52 | 2026-10-06 | `counterparty_id` склейки — первая сопоставленная строка самого позднего квартала, где сопоставленные есть; `match_kind` и `match_stored = true` — от неё же; в `warnings` (по строке на источник: «Qn: строка <id> сопоставлена с <id>, в своде — <id>») попадают только строки с ДРУГИМ непустым `counterparty_id`, пустые расхождением не считаются. Дорезолв (`Resolver.resolve(inn, name)`) вызывается только когда сопоставленных нет, по ИНН/имени «как в файле» той же строки; не найден → `match_stored = false`, `match_kind = null`, `EXTERNAL` | D-3 и решение №8; несопоставленная строка рядом с сопоставленной — обычное состояние до автоприменения | текст предупреждений — не контракт, фронт показывает как есть |
| 53 | 2026-10-06 | Порядок счетов в своде и пробелах — натуральный (`account_sort_key`, перенесён из чек-листа в `domain/osv_mapping.py`: `9 < 60 < 60.2 < 60.10`), при равенстве — код строкой; внутри имени ЮЛ — ещё `legal_entity_id` (тёзки не перемешиваются) | одно определение порядка счетов для чек-листа и свода (№48); спека говорит «account_code», текстовая сортировка ставит `60.10` раньше `60.2` | порядок строк в UI/экспорте при необычных кодах |
| 54 | 2026-10-06 | `build` защитно игнорирует документы ЮЛ вне `members` и строки документов, которых нет в `documents`; `documents_total` = число переданных документов. `select_sources` при дублях (два нарастающих за квартал версии или два квартальных за квартал) возвращает все — привязка их не допускает (R-1) | вход формирует репозиторий по `consolidation_document`; дубли невозможны по ключу привязки | при нарушении инварианта привязки — двойной счёт строк, а не ошибка |
| 55 | 2026-10-06 | `OsvFilter.q` обрезается по краям, пустой не фильтрует; имя — подстрока без регистра (`casefold`), ИНН — префикс после удаления пробелов с обеих сторон. `unmapped = false` — только сопоставленные (после дорезолва); `match_kinds` пустой — без фильтра | спека задаёт только `unmapped = true`; симметричное значение естественно для query-параметра `bool` | фронт получит сопоставленные строки на `unmapped=false` вместо всех |
| 56 | 2026-10-06 | Признак `CounterpartyType` (StrEnum) и `counts_as_cumulative`/`QUARTER_ORDER`/`quarters_through` — по одному определению в домене: схема API берёт `CounterpartyType` из `domain.consolidated_osv` (T2.8), `services.consolidation_documents` — `QUARTER_ORDER` и `counts_as_cumulative` (R-1), чек-лист — `select_sources` через адаптер `_osv_coverage` (T8.1) и явную таблицу статусов кандидатов (T8.4) | расходящиеся копии — источник тихого рассинхрона | нулевая: OpenAPI-enum и поведение те же, тесты чек-листа не менялись |

View File

@ -43,7 +43,7 @@
## 4. Сводная ОСВ ## 4. Сводная ОСВ
- [ ] 4.1 **Домен.** `domain/consolidation_periods.py:select_sources` (нарастающий/квартальные/пробел), `domain/consolidated_osv.py: - [x] 4.1 **Домен.** `domain/consolidation_periods.py:select_sources` (нарастающий/квартальные/пробел), `domain/consolidated_osv.py:
build(...)` (склейка по ключу, нули, дубли, `entry_ids`, `warnings`, дорезолв, признак, фильтры, порядок). Тесты без БД: build(...)` (склейка по ключу, нули, дубли, `entry_ids`, `warnings`, дорезолв, признак, фильтры, порядок). Тесты без БД:
все сценарии «Выбор источников», «Строки свода», фильтры, версия за Q1, дубль ключа, расхождение `counterparty_id`. все сценарии «Выбор источников», «Строки свода», фильтры, версия за Q1, дубль ключа, расхождение `counterparty_id`.
- [ ] 4.2 **API свода.** `services/consolidated_osv.py:page/summary`, `GET …/osv`, `GET …/osv/summary`. Тесты: страница на - [ ] 4.2 **API свода.** `services/consolidated_osv.py:page/summary`, `GET …/osv`, `GET …/osv/summary`. Тесты: страница на