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:
parent
f46cf2fec5
commit
d5a317a040
@ -17,23 +17,21 @@
|
||||
"""
|
||||
|
||||
import enum
|
||||
import re
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
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
|
||||
|
||||
__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}
|
||||
_UNORDERED = len(_TYPE_ORDER)
|
||||
_UNCONFIRMED_PRIORITY = {DocumentStatus.NEEDS_CHECK: 0, DocumentStatus.RECOGNIZED: 1}
|
||||
|
||||
|
||||
class CellStatus(enum.StrEnum):
|
||||
@ -47,6 +45,15 @@ class CellStatus(enum.StrEnum):
|
||||
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)
|
||||
class EntityFilesState:
|
||||
"""Состояние файлов ЮЛ: `has_in_progress` — есть файл `UPLOADED`/`PROCESSING`; `last_file_status` — статус
|
||||
@ -101,16 +108,11 @@ class DocumentLike(Protocol):
|
||||
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]:
|
||||
"""Порядок столбцов чек-листа: `BALANCE`, `INCOME`, затем ОСВ по натуральной сортировке кода счёта."""
|
||||
return sorted(
|
||||
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:
|
||||
"""Покрытие ОСВ одного счёта ЮЛ: нарастающий за квартал версии, либо квартальные Q1..Qn версии все; нарастающий
|
||||
приоритетнее квартальных; пробел — `missing_quarters` и документы имеющихся кварталов.
|
||||
"""Покрытие ОСВ одного счёта ЮЛ — адаптер над `consolidation_periods.select_sources` (D-2, T8.1).
|
||||
|
||||
Для версии Q1 квартальный Q1 равен нарастающему. Документы вне квартала версии (нарастающий за другой квартал,
|
||||
квартальный позже версии) покрытия не дают.
|
||||
|
||||
# Task 9 заменит на consolidation_periods.select_sources
|
||||
Полное покрытие — источники без пробела (нарастающий за квартал версии или квартальные `Q1..Qn` все). При пробеле
|
||||
`Gap` документов не несёт, поэтому адаптер сам берёт документы имеющихся кварталов (квартальные `Q1..Qn` в порядке
|
||||
кварталов) и перечисляет `missing_quarters` только при частичном покрытии: без единого документа — `()` (№46).
|
||||
"""
|
||||
cumulative = [d for d in documents if d.is_cumulative is not False and d.period_quarter == version_quarter]
|
||||
if cumulative:
|
||||
return _Coverage(True, (), tuple(d.id for d in cumulative))
|
||||
last = _QUARTER_ORDER[version_quarter]
|
||||
needed = _QUARTERS[: last + 1]
|
||||
by_quarter: dict[PeriodQuarter, list[DocumentLike]] = defaultdict(list)
|
||||
for document in documents:
|
||||
if document.is_cumulative is False and document.period_quarter in needed and document.period_quarter:
|
||||
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))
|
||||
selected = select_sources(version_quarter, documents)
|
||||
if isinstance(selected, Sources):
|
||||
return _Coverage(True, (), tuple(d.id for d in selected.documents))
|
||||
needed = quarters_through(version_quarter)
|
||||
present = tuple(
|
||||
d.id for quarter in needed for d in documents if d.is_cumulative is False and d.period_quarter == quarter
|
||||
)
|
||||
return _Coverage(False, selected.missing_quarters if present else (), present)
|
||||
|
||||
|
||||
def _coverage(version_quarter: PeriodQuarter, rule: Rule, documents: Sequence[DocumentLike]) -> _Coverage:
|
||||
@ -176,10 +170,10 @@ def _cell(
|
||||
def partial(status: CellStatus) -> Cell:
|
||||
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:
|
||||
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:
|
||||
return partial(CellStatus.PROCESSING)
|
||||
if state.last_file_status == FileStatus.ERROR:
|
||||
|
||||
505
api/src/domain/consolidated_osv.py
Normal file
505
api/src/domain/consolidated_osv.py
Normal 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
|
||||
@ -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 uuid
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic, Protocol, TypeVar
|
||||
|
||||
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 января следующего года
|
||||
_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)
|
||||
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)
|
||||
|
||||
@ -33,6 +33,7 @@ __all__ = [
|
||||
"MappedSheet",
|
||||
"Resolution",
|
||||
"account_root",
|
||||
"account_sort_key",
|
||||
"build_entries",
|
||||
"counterparty_check",
|
||||
"document_fields",
|
||||
@ -51,6 +52,7 @@ _NUMERIC_SEGMENT_RE = re.compile(r"[0-9]{1,3}")
|
||||
_ROOT_SEGMENT_RE = re.compile(r"[0-9]+")
|
||||
_NON_DIGITS_RE = re.compile(r"[^0-9]")
|
||||
_SEGMENT_SEPARATOR = "."
|
||||
_ACCOUNT_SPLIT_RE = re.compile(r"[.\s]+")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -126,6 +128,13 @@ def account_root(code: str) -> str | 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, ...]:
|
||||
return (
|
||||
row.opening_debit,
|
||||
|
||||
@ -8,11 +8,11 @@
|
||||
import datetime
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.domain.checklist import CellStatus
|
||||
from src.domain.consolidated_osv import CounterpartyType # признак контрагента §3: одно определение (T2.8)
|
||||
from src.models.enums import (
|
||||
ConsolidationApproach,
|
||||
ConsolidationScenario,
|
||||
@ -27,8 +27,6 @@ YEAR_MAX = 2100
|
||||
DEFAULT_OSV_LIMIT = 500
|
||||
MAX_OSV_LIMIT = 2000
|
||||
MAX_TAKEOVER_REASON = 1000
|
||||
# признак контрагента относительно версии (§3): ГСК — в периметре, ГК — в группе, но вне периметра, иначе внешний
|
||||
CounterpartyType = Literal["GSK", "GK", "EXTERNAL"]
|
||||
|
||||
|
||||
class RefOut(BaseModel):
|
||||
|
||||
@ -27,6 +27,7 @@ from sqlalchemy import delete, select
|
||||
from sqlalchemy.engine import CursorResult
|
||||
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.models.consolidation import Consolidation, ConsolidationDocument, ConsolidationMember, ConsolidationVersion
|
||||
from src.models.enums import ConsolidationStatus, DocumentStatus, DocumentType, FileStatus, PeriodQuarter
|
||||
@ -45,7 +46,6 @@ __all__ = [
|
||||
|
||||
#: Статусы, из которых подтверждение привязанного документа переводит версию в `DATA_CORRECTION` (D-6).
|
||||
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]
|
||||
|
||||
@ -56,8 +56,8 @@ def _rowcount(result: Any) -> int:
|
||||
|
||||
|
||||
def _cumulative(document: Document) -> bool:
|
||||
"""Нарастающий ли документ: `is_cumulative` true или NULL (ББ/ОФР всегда за период с начала года, решение №10)."""
|
||||
return document.is_cumulative is not False
|
||||
"""Нарастающий ли документ (R-1, решение №10): `consolidation_periods.counts_as_cumulative` — NULL нарастающий."""
|
||||
return counts_as_cumulative(document.is_cumulative)
|
||||
|
||||
|
||||
def _key(document: Document) -> BindingKey:
|
||||
@ -92,7 +92,7 @@ def fits(
|
||||
return False
|
||||
if _cumulative(document):
|
||||
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]:
|
||||
|
||||
499
api/tests/test_domain_consolidated_osv.py
Normal file
499
api/tests/test_domain_consolidated_osv.py
Normal 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"]
|
||||
@ -1,12 +1,24 @@
|
||||
"""Отчётная дата консолидации по году и кварталу (spec consolidations-api «Создание консолидации»; OQ-40)."""
|
||||
"""Период консолидации (Срез 4): отчётная дата (OQ-40) и выбор источников ОСВ на пару (ЮЛ, счёт) — D-2, §2."""
|
||||
|
||||
import datetime
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
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
|
||||
|
||||
Q1, Q2, Q3, Q4 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("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:
|
||||
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,))
|
||||
|
||||
@ -8,7 +8,9 @@ import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
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.schemas import consolidations as schemas
|
||||
from src.schemas.consolidations import (
|
||||
ChecklistCellOut,
|
||||
ConsolidatedRowOut,
|
||||
@ -89,3 +91,11 @@ def test_checklist_cell_status_is_the_domain_enum() -> None:
|
||||
assert cell.status is status
|
||||
with pytest.raises(ValidationError):
|
||||
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]
|
||||
|
||||
@ -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]`.
|
||||
- Ключ склейки: `(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`.
|
||||
- [ ] **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 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 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 5: Commit** `feat(domain): сводная ОСВ — выбор источников, приведение периодов, пробелы, признак контрагента`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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 не получит ссылку на документ-кандидат из чек-листа (она есть в списке документов ЮЛ) |
|
||||
| 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 | нулевая |
|
||||
| 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 и поведение те же, тесты чек-листа не менялись |
|
||||
|
||||
@ -43,7 +43,7 @@
|
||||
|
||||
## 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`, дорезолв, признак, фильтры, порядок). Тесты без БД:
|
||||
все сценарии «Выбор источников», «Строки свода», фильтры, версия за Q1, дубль ключа, расхождение `counterparty_id`.
|
||||
- [ ] 4.2 **API свода.** `services/consolidated_osv.py:page/summary`, `GET …/osv`, `GET …/osv/summary`. Тесты: страница на
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user