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 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:
|
||||||
|
|||||||
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 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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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]:
|
||||||
|
|||||||
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 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,))
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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): сводная ОСВ — выбор источников, приведение периодов, пробелы, признак контрагента`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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 и поведение те же, тесты чек-листа не менялись |
|
||||||
|
|||||||
@ -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`. Тесты: страница на
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user