AURORA-1536 #175

Merged
aep merged 8 commits from AURORA-1536 into main 2026-10-23 22:01:59 +03:00
24 changed files with 397 additions and 136 deletions
Showing only changes of commit b4522addc0 - Show all commits

View File

@ -175,7 +175,7 @@
- NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`.
## Правила репозитория
- ORM 1:1 с `../docs/model/data-model-v5.22.dbml` (текущая версия; v5.22 структуру не меняла; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19` + `0008_dbml_v5_20` + `0009_dbml_v5_21`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка.
- ORM 1:1 с `../docs/model/data-model-v5.23.dbml` (текущая версия; v5.22 структуру не меняла; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18` + `0007_dbml_v5_19` + `0008_dbml_v5_20` + `0009_dbml_v5_21` + `0010_dbml_v5_23`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка.
- Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring.
- Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц.
- Фронт (`web/`) разрабатывается здесь (с 21.10.2026): фронтовые задачи — worktree arm-core и PR в `main`, как и

View File

@ -3,7 +3,7 @@ VENV := api/.venv
BIN := $(CURDIR)/$(VENV)/bin
COMPOSE_FILE ?= ../docs/dev/docker-compose.yml
# DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/
DBML ?= ../../docs/model/data-model-v5.22.dbml
DBML ?= ../../docs/model/data-model-v5.23.dbml
.PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-check statements-check check-full up migrate seed seed-dev

View File

@ -1127,7 +1127,7 @@ email без учёта регистра проверяет только сер
| `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен |
| `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) |
| `make e2e` | сквозной прогон загрузки ОСВ: `pytest -m e2e`, нужны compose и tabular-parser из профиля `services` (без него — skip) |
| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.22.dbml` |
| `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.23.dbml` |
| `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate |
| `make check` | быстрая проверка без Docker: `lint` + `test` |
| `make docker-build` | сборка образа: `docker build -t arm-core .` |
@ -1179,7 +1179,7 @@ queue group `arm-core`, подписчики группы получают со
### Проверка ORM и DBML
Модель данных — `../docs/model/data-model-v5.22.dbml`. ORM обязана совпадать с ней один в один:
Модель данных — `../docs/model/data-model-v5.23.dbml`. ORM обязана совпадать с ней один в один:
```bash
make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно
@ -1202,7 +1202,7 @@ make migration-cycle # проверить цикл upgr
```
api/
run_server.py точка входа (uvicorn; хост и порт — из настроек)
alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17, 0006_dbml_v5_18.py — v5.18, 0007_dbml_v5_19.py — v5.19, 0008_dbml_v5_20.py — v5.20, 0009_dbml_v5_21.py — v5.21)
alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17, 0006_dbml_v5_18.py — v5.18, 0007_dbml_v5_19.py — v5.19, 0008_dbml_v5_20.py — v5.20, 0009_dbml_v5_21.py — v5.21, 0010_dbml_v5_23.py — v5.23; v5.22 структуру не меняла)
rules/ statement_lines.yaml — справочник имён строк форм ББ/ОФР по редакциям (словарь проверок §7 — в пакете arm_statements)
seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист)
seed/dev/ dev-данные: филиалы и пользователи по ролям
@ -1286,7 +1286,7 @@ Dockerfile только для локального compose
- [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы;
- [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3;
- [`../docs/context/04-data-model-guide.md`](../docs/context/04-data-model-guide.md) и
[`../docs/model/data-model-v5.22.dbml`](../docs/model/data-model-v5.22.dbml) — модель данных;
[`../docs/model/data-model-v5.23.dbml`](../docs/model/data-model-v5.23.dbml) — модель данных;
- [`../docs/context/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области;
- [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.md) — открытые вопросы;
- [`../docs/context/09-delivery-plan.md`](../docs/context/09-delivery-plan.md) — план срезов;

View File

@ -0,0 +1,28 @@
"""DBML v5.23: consolidation_version.elimination_rules_version NOT NULL; пустые — по дате создания (§4, OQ-174)."""
from alembic import op
from src.models.base import SCHEMA
revision = "0010_dbml_v5_23"
down_revision = "0009_dbml_v5_21"
branch_labels = None
depends_on = None
def upgrade() -> None:
# действующая на дату создания (по Москве), иначе последняя открытая; без правил версия не заполнится — отказ ниже
created = "(COALESCE(v.created_at, now()) AT TIME ZONE 'Europe/Moscow')::date"
op.execute(f"""UPDATE {SCHEMA}.consolidation_version v SET elimination_rules_version = COALESCE(
(SELECT max(r.version_no) FROM {SCHEMA}.elimination_rule r
WHERE r.valid_from <= {created} AND (r.valid_to IS NULL OR r.valid_to >= {created})),
(SELECT max(r.version_no) FROM {SCHEMA}.elimination_rule r WHERE r.valid_to IS NULL))
WHERE v.elimination_rules_version IS NULL""")
op.execute(f"""DO $$ DECLARE n bigint; BEGIN
SELECT count(*) INTO n FROM {SCHEMA}.consolidation_version WHERE elimination_rules_version IS NULL;
IF n > 0 THEN RAISE EXCEPTION 'миграция v5.23 невозможна: нет правил элиминации для версий консолидации: %', n;
END IF; END $$""")
op.alter_column("consolidation_version", "elimination_rules_version", nullable=False, schema=SCHEMA)
def downgrade() -> None:
op.alter_column("consolidation_version", "elimination_rules_version", nullable=True, schema=SCHEMA)

View File

@ -57,7 +57,7 @@ from sqlalchemy.sql.functions import FunctionElement
#: Эталонная схема модели. Имя схемы в БД конфигурируемо (`DB_SCHEMA`), CLI сверяет ORM, загруженную с этим значением.
SCHEMA = "dkb"
DBML_FILE_NAME = "data-model-v5.22.dbml"
DBML_FILE_NAME = "data-model-v5.23.dbml"
ORM = "ORM"
DBML = "DBML"
ABSENT = "нет"

View File

@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, status
from src.api.dependencies.auth import require
from src.api.dependencies.consolidations import EliminationRuleServiceDep, IntercompanyMatrixServiceDep
from src.schemas.common import ErrorDetail
from src.schemas.consolidations import EliminationPairsOut, EliminationRulesOut
from src.schemas.consolidations import EliminationPairsOut, EliminationRulesOut, EliminationTotalsOut
router = APIRouter(prefix="/consolidations/versions")
@ -37,6 +37,21 @@ async def list_rules(
return await service.list_rules(version_id)
@router.get(
"/{version_id}/elimination-totals",
response_model=EliminationTotalsOut,
dependencies=VIEW,
responses=PAIRS_RESPONSES,
summary="Пары ВГО версии: итоги всех троек",
)
async def build_elimination_totals(
version_id: uuid.UUID,
service: IntercompanyMatrixServiceDep,
) -> EliminationTotalsOut:
"""Правила ББ × упорядоченные пары компаний периметра: суммы сторон, расхождение, элиминация, статус (§5)."""
return await service.build_totals(version_id)
@router.get(
"/{version_id}/elimination-pairs",
response_model=EliminationPairsOut,

View File

@ -1,4 +1,4 @@
"""ORM-модели. Источник истины — docs/model/data-model-v5.22.dbml; заполняется в Срезе 0.
"""ORM-модели. Источник истины — docs/model/data-model-v5.23.dbml; заполняется в Срезе 0.
Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic).
"""
@ -7,7 +7,7 @@ from . import access, accounting, audit_ex, consolidation, directories, files, k
from .base import Base, UUIDPrimaryMixin
#: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm).
DBML_VERSION = "5.22"
DBML_VERSION = "5.23"
__all__ = [
"DBML_VERSION",

View File

@ -67,7 +67,7 @@ class ConsolidationVersion(UUIDPrimaryMixin, Base):
uob_decision_no: Mapped[str | None] = mapped_column(String)
uob_decision_date: Mapped[datetime.date | None] = mapped_column(Date)
methodology_deviations: Mapped[str | None] = mapped_column(Text)
elimination_rules_version: Mapped[int | None] = mapped_column(Integer)
elimination_rules_version: Mapped[int] = mapped_column(Integer, nullable=False)
is_in_work: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
previous_version_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),

View File

@ -11,21 +11,13 @@ from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRu
async def list_rules_with_accounts(
session: AsyncSession, version_no: int | None
session: AsyncSession, version_no: int
) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]:
"""Правила §4 заданной либо последней открытой версии."""
condition = (
EliminationRule.version_no == version_no
if version_no is not None
else (
EliminationRule.valid_to.is_(None)
& (EliminationRule.version_no == _current_open_version_stmt().scalar_subquery())
)
)
"""Правила §4 заданной версии."""
stmt = (
select(EliminationRule, EliminationRuleAccount)
.outerjoin(EliminationRuleAccount, EliminationRuleAccount.rule_id == EliminationRule.id)
.where(condition)
.where(EliminationRule.version_no == version_no)
.order_by(
EliminationRule.statement_type,
EliminationRule.rule_no,
@ -35,16 +27,6 @@ async def list_rules_with_accounts(
return [(rule, account) for rule, account in (await session.execute(stmt)).all()]
def _current_open_version_stmt():
"""Запрос номера последней открытой версии правил §4."""
return select(func.max(EliminationRule.version_no)).where(EliminationRule.valid_to.is_(None))
async def current_open_version_no(session: AsyncSession) -> int | None:
"""Номер последней версии с открытыми правилами §4."""
return await session.scalar(_current_open_version_stmt())
async def latest_effective_version_no(session: AsyncSession, as_of: date) -> int | None:
"""Максимальный номер версии правил, действующей на дату создания"""
return await session.scalar(
@ -55,9 +37,9 @@ async def latest_effective_version_no(session: AsyncSession, as_of: date) -> int
)
def is_valid_for_version(rule: EliminationRule | None, version_no: int | None) -> bool:
"""Проверить версию правила §4 либо отсутствие `valid_to`."""
return rule is not None and (rule.version_no == version_no if version_no is not None else rule.valid_to is None)
def is_valid_for_version(rule: EliminationRule | None, version_no: int) -> bool:
"""Правило §4 из зафиксированной версии правил версии консолидации."""
return rule is not None and rule.version_no == version_no
async def list_rules(session: AsyncSession, version_no: int) -> list[EliminationRule]:

View File

@ -388,6 +388,41 @@ class EliminationPairsOut(BaseModel):
unpaired: list[EliminationUnpairedRowOut]
EliminationTotalStatus = Literal["AUTO", "DISCREPANCY", "ONE_SIDED", "MANUAL"]
class EliminationTotalItemOut(BaseModel):
"""Итог тройки (правило, компания A, компания B) в списке версии (§5 п. 3)."""
rule_id: uuid.UUID
rule_no: int
elimination_mode: str
company_a_id: uuid.UUID
company_b_id: uuid.UUID
amount_a: Decimal
amount_b: Decimal
deviation_amount: Decimal
deviation_percent: Decimal | None
eliminated_amount: Decimal
exceeds_threshold: bool
status: EliminationTotalStatus
osv_entry_ids_a: list[uuid.UUID]
osv_entry_ids_b: list[uuid.UUID]
@field_validator("amount_a", "amount_b", "deviation_amount", "deviation_percent", "eliminated_amount")
@classmethod
def _two_decimals(cls, value: Decimal | None) -> Decimal | None:
"""Суммы и процент в JSON всегда с двумя знаками; неопределённый процент — null."""
return None if value is None else value.quantize(CENT, rounding=ROUND_HALF_UP)
class EliminationTotalsOut(BaseModel):
"""Все тройки версии консолидации по правилам ББ зафиксированной версии правил."""
version_no: int
items: list[EliminationTotalItemOut]
class IntercompanyMatrixManualIn(BaseModel):
"""Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки."""

View File

@ -332,6 +332,10 @@ class ConsolidationService:
"""Записывает консолидацию, версию №1, периметр и первую запись истории; подхватывает документы."""
now = utc_now()
user = self._principal.user
# версия правил §4 — действующая на дату создания (OQ-174), с v5.23 обязательна
rules_version = await elimination_rules_repo.latest_effective_version_no(self._session, moscow_date(now))
if rules_version is None:
raise ConflictError("ELIMINATION_RULES_MISSING", "Нет правил элиминации, действующих на дату создания")
consolidation = Consolidation(
company_group_id=group_id,
year=data.year,
@ -348,9 +352,7 @@ class ConsolidationService:
scenario=ConsolidationScenario.CREATE,
approach=ConsolidationApproach.BASIC,
goodwill_enabled=False,
elimination_rules_version=await elimination_rules_repo.latest_effective_version_no(
self._session, moscow_date(now)
),
elimination_rules_version=rules_version,
initiator_id=user.id,
initiator_office_id=user.regional_office_id,
is_in_work=True,

View File

@ -26,11 +26,9 @@ class EliminationRuleService:
if found is None:
raise NotFoundError(VERSION_NOT_FOUND)
version, _ = found
is_version_fixed = version.elimination_rules_version is not None
# с v5.23 версия правил фиксируется при создании версии консолидации и не бывает пустой
version_no = version.elimination_rules_version
rows = await rules_repo.list_rules_with_accounts(self._session, version_no)
if not is_version_fixed and rows:
version_no = rows[0][0].version_no
by_id: dict[uuid.UUID, EliminationRuleOut] = {}
for rule, account in rows:
result = by_id.get(rule.id)
@ -56,6 +54,6 @@ class EliminationRuleService:
return EliminationRulesOut(
version_no=version_no,
is_version_fixed=is_version_fixed,
is_version_fixed=True,
rules=list(by_id.values()),
)

View File

@ -31,6 +31,9 @@ from src.schemas.consolidations import (
EliminationMatchedPairOut,
EliminationPairRowOut,
EliminationPairsOut,
EliminationTotalItemOut,
EliminationTotalsOut,
EliminationTotalStatus,
EliminationUnpairedRowOut,
IntercompanyMatrixCellOut,
IntercompanyMatrixCompanyOut,
@ -402,12 +405,23 @@ def _matched_pair_out(
)
def _pack_preset_rows(
rows: Sequence[ConsolidatedRow],
selection: MatrixSelection,
context: PairContext,
) -> RulePacking:
"""Отобрать встречные строки одной направленной пары компаний и одного правила."""
@dataclass(frozen=True)
class RuleSides:
"""Встречные строки тройки: исходные строки свода и строки сторон для сумм и упаковки."""
sources: dict[RowKey, ConsolidatedRow]
debit_rows: list[PackRow]
credit_rows: list[PackRow]
def a_b(self, rule: EliminationRule) -> tuple[list[PackRow], list[PackRow]]:
"""Строки стороны A (side_1 правила) и стороны B."""
if rule.side_1 == "D":
return self.debit_rows, self.credit_rows
return self.credit_rows, self.debit_rows
def _collect_rule_sides(rows: Sequence[ConsolidatedRow], selection: MatrixSelection, context: PairContext) -> RuleSides:
"""Отобрать встречные строки одной направленной пары компаний и одного правила (§5)."""
rule = context.rule
company_a_id = context.company_a_id
company_b_id = context.company_b_id
@ -439,12 +453,73 @@ def _pack_preset_rows(
debit_rows.append(PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, debit_amount))
if in_credit and credit_amount > ZERO:
credit_rows.append(PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, credit_amount))
return RuleSides(sources, debit_rows, credit_rows)
rows_a = debit_rows if rule.side_1 == "D" else credit_rows
rows_b = credit_rows if rule.side_1 == "D" else debit_rows
def _rule_totals(sides: RuleSides, rule: EliminationRule) -> RuleTotals:
"""Итог тройки по строкам сторон: ΣA, ΣB, расхождение и сумма элиминации (§5 п. 1)."""
rows_a, rows_b = sides.a_b(rule)
amount_a = sum((row.amount for row in rows_a), ZERO)
amount_b = sum((row.amount for row in rows_b), ZERO)
totals = calculate_rule_totals(amount_a, amount_b, manual=rule.elimination_mode == "manual")
return calculate_rule_totals(amount_a, amount_b, manual=rule.elimination_mode == "manual")
def _total_status(totals: RuleTotals, rule: EliminationRule) -> EliminationTotalStatus:
"""Статус тройки в списке версии (§5 п. 3)."""
if rule.elimination_mode == "manual":
return "MANUAL"
if ZERO in (totals.amount_a, totals.amount_b):
return "ONE_SIDED"
return "DISCREPANCY" if totals.exceeds_threshold else "AUTO"
def calculate_rule_total_items(
rows: Sequence[ConsolidatedRow],
rules: Sequence[tuple[EliminationRule, MatrixSelection]],
company_ids: Sequence[uuid.UUID],
) -> list[EliminationTotalItemOut]:
"""Итоги всех троек версии: правило × упорядоченная пара компаний; тройки без строк пропускаются (§5 п. 3)."""
items: list[EliminationTotalItemOut] = []
for rule, selection in rules:
for company_a_id in company_ids:
for company_b_id in company_ids:
if company_a_id == company_b_id:
continue
sides = _collect_rule_sides(rows, selection, PairContext(rule, company_a_id, company_b_id))
if not sides.debit_rows and not sides.credit_rows:
continue
totals = _rule_totals(sides, rule)
rows_a, rows_b = sides.a_b(rule)
items.append(
EliminationTotalItemOut(
rule_id=rule.id,
rule_no=rule.rule_no,
elimination_mode=rule.elimination_mode,
company_a_id=company_a_id,
company_b_id=company_b_id,
amount_a=totals.amount_a,
amount_b=totals.amount_b,
deviation_amount=totals.deviation_amount,
deviation_percent=totals.deviation_percent,
eliminated_amount=totals.eliminated_amount,
exceeds_threshold=totals.exceeds_threshold,
status=_total_status(totals, rule),
osv_entry_ids_a=[entry_id for row in rows_a for entry_id in row.key],
osv_entry_ids_b=[entry_id for row in rows_b for entry_id in row.key],
)
)
return items
def _pack_preset_rows(
rows: Sequence[ConsolidatedRow],
selection: MatrixSelection,
context: PairContext,
) -> RulePacking:
"""Итог тройки и упаковка её встречных строк (§5 п. 1–2)."""
sides = _collect_rule_sides(rows, selection, context)
sources, debit_rows, credit_rows = sides.sources, sides.debit_rows, sides.credit_rows
totals = _rule_totals(sides, context.rule)
packed = pack_rule_rows(debit_rows, credit_rows)
matched: list[EliminationMatchedPairOut] = []
with_discrepancy: list[EliminationMatchedPairOut] = []
@ -511,6 +586,22 @@ class IntercompanyMatrixService:
unpaired=packing.unpaired,
)
async def build_totals(self, version_id: uuid.UUID) -> EliminationTotalsOut:
"""Итоги всех троек версии по правилам ББ её версии правил; сводная ОСВ строится один раз (§5 п. 3)."""
osv_result = await self._osv_service.build_with_members(version_id)
version = osv_result.version
accounts = await directory_repo.list_accounts(self._session)
by_id, children = _make_account_tree(accounts)
rules: list[tuple[EliminationRule, MatrixSelection]] = []
for rule in await elimination_rules_repo.list_rules(self._session, version.elimination_rules_version):
# пары §5 — только правила ББ по сальдо; правила ОФР ещё не формализованы (§4)
if rule.statement_type != StatementType.balance or rule.measure != "closing":
continue
selection, _ = await self._resolve_preset_selection(rule.id, version, by_id, children)
rules.append((rule, selection))
items = calculate_rule_total_items(osv_result.consolidated.rows, rules, list(osv_result.members))
return EliminationTotalsOut(version_no=version.elimination_rules_version, items=items)
async def build(
self,
version_id: uuid.UUID,
@ -637,10 +728,6 @@ class IntercompanyMatrixService:
rule, rule_accounts = await elimination_rules_repo.rule_with_account(self._session, rule_id)
if rule is None or not elimination_rules_repo.is_valid_for_version(rule, version.elimination_rules_version):
raise NotFoundError("правило-пресет не найдено для версии консолидации")
if version.elimination_rules_version is None:
open_version_no = await elimination_rules_repo.current_open_version_no(self._session)
if rule.version_no != open_version_no:
raise NotFoundError("правило-пресет не найдено для версии консолидации")
if rule.statement_type != StatementType.balance:
raise DomainValidationError("INVALID_RULE", "Шахматка PRESET поддерживает только правила ББ")
if rule.side_1 not in ("D", "K") or rule.measure not in ("closing", "turnover"):

View File

@ -415,6 +415,7 @@ async def make_version( # noqa: PLR0913 - сигнатура фабрики ф
scenario=ConsolidationScenario.CREATE,
approach=ConsolidationApproach.BASIC,
goodwill_enabled=False,
elimination_rules_version=1, # seed правил §4 — версия v1; с DBML v5.23 поле обязательно
initiator_id=initiator.id,
initiator_office_id=initiator.regional_office_id,
is_in_work=status not in FINISHED_STATUSES,

View File

@ -166,6 +166,23 @@ async def test_create_consolidation_rb_q3_2024(auth_client: AuthClientFactory, s
}
@pytest.mark.integration
async def test_create_without_effective_rules_is_conflict(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""DBML v5.23: версия правил обязательна — без правил, действующих на дату создания, консолидация не создаётся."""
group, gsk, _ = await _scenario(seeded)
tomorrow = moscow_date(utc_now()) + timedelta(days=1)
for rule in (await seeded.scalars(select(EliminationRule))).all():
rule.valid_from = tomorrow
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.post(CONSOLIDATIONS, json=_body(group, gsk, year=2024))
assert response.status_code == HTTP_CONFLICT, response.text
assert response.json()["detail"]["code"] == "ELIMINATION_RULES_MISSING"
assert await _count(seeded, Consolidation) == 0
@pytest.mark.integration
async def test_create_fixes_rules_for_creation_date_not_reporting_period(
auth_client: AuthClientFactory, seeded: AsyncSession

View File

@ -202,18 +202,17 @@ async def test_preset_rejects_income_rule(auth_client: AuthClientFactory, seeded
assert response.json()["detail"]["code"] == "INVALID_RULE"
async def test_preset_uses_current_rules_when_version_unfixed(
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
async def test_preset_uses_fixed_rules_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
world = await _world(seeded)
rule = (
await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO))
).one()
assert world.version.elimination_rules_version == rule.version_no
client = await auth_client(KA_RF)
url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}"
listed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules")
assert listed.status_code == OK, listed.text
assert listed.json()["is_version_fixed"] is False
assert listed.json()["is_version_fixed"] is True
assert listed.json()["version_no"] == rule.version_no
assert "as_of" not in listed.json()
assert str(rule.id) in {item["id"] for item in listed.json()["rules"]}
@ -226,36 +225,31 @@ async def test_preset_uses_current_rules_when_version_unfixed(
},
)
assert pairs.status_code == OK, pairs.text
assert world.version.elimination_rules_version is None # GET не фиксирует номер правил
response = await client.get(url)
assert response.status_code == OK, response.text
assert (await client.get(f"{url}/export")).status_code == OK
audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one()
assert audit.after_value["rule_no"] == rule.rule_no
assert audit.after_value["rule_version_no"] == rule.version_no
assert world.version.elimination_rules_version is None
assert world.version.elimination_rules_version == rule.version_no # GET номер правил не меняет
world.version.elimination_rules_version = rule.version_no + 1
await seeded.flush()
assert (await client.get(url)).status_code == NOT_FOUND
assert (await client.get(f"{url}/export")).status_code == NOT_FOUND
world.version.elimination_rules_version = rule.version_no
await seeded.flush()
fixed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules")
assert fixed.status_code == OK, fixed.text
assert fixed.json()["is_version_fixed"] is True
assert fixed.json()["version_no"] == rule.version_no
missing_url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{uuid.uuid4()}"
missing = await client.get(missing_url)
assert missing.status_code == NOT_FOUND
assert (await client.get(f"{missing_url}/export")).status_code == NOT_FOUND
async def test_unfixed_rules_choose_latest_open_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
async def test_fixed_rules_version_ignores_newer_rules(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
"""Новая версия правил после создания версии консолидации на неё не влияет (§4, DBML v5.23)."""
world = await _world(seeded)
old_rule, new_rule = (
await seeded.scalars(select(EliminationRule).order_by(EliminationRule.rule_no).limit(2))
).all()
assert old_rule.valid_to is None and new_rule.valid_to is None
new_rule.rule_no = old_rule.rule_no
new_rule.version_no = old_rule.version_no + 1
await seeded.flush()
@ -265,13 +259,13 @@ async def test_unfixed_rules_choose_latest_open_version(auth_client: AuthClientF
response = await client.get(f"{base}/elimination-rules")
assert response.status_code == OK, response.text
payload = response.json()
assert payload["is_version_fixed"] is False
assert payload["version_no"] == new_rule.version_no
assert [item["id"] for item in payload["rules"]] == [str(new_rule.id)]
assert payload["rules"][0]["rule_no"] == old_rule.rule_no
assert payload["is_version_fixed"] is True
assert payload["version_no"] == old_rule.version_no
assert str(new_rule.id) not in {item["id"] for item in payload["rules"]}
assert str(old_rule.id) in {item["id"] for item in payload["rules"]}
old_preset = await client.get(f"{base}/intercompany-matrix/preset/{old_rule.id}")
assert old_preset.status_code == NOT_FOUND
new_preset = await client.get(f"{base}/intercompany-matrix/preset/{new_rule.id}")
assert new_preset.status_code == NOT_FOUND
async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915
@ -522,6 +516,32 @@ async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915
missing = await client.get(f"/consolidations/versions/{uuid.uuid4()}/elimination-pairs", params=params)
assert missing.status_code == NOT_FOUND
# список версии — те же итоги, что у тройки, одним запросом (§5 п. 3)
totals = await client.get(f"/consolidations/versions/{world.version.id}/elimination-totals")
assert totals.status_code == OK, totals.text
assert totals.json()["version_no"] == 1
by_triple = {(item["rule_no"], item["company_a_id"], item["company_b_id"]): item for item in totals.json()["items"]}
a, b, c = str(company_a_id), str(company_b_id), str(company_c_id)
main = by_triple[(6, a, b)]
assert (main["amount_a"], main["amount_b"], main["deviation_amount"]) == ("300.00", "100.00", "200.00")
assert (main["deviation_percent"], main["eliminated_amount"]) == ("200.00", "0.00")
assert (main["status"], main["exceeds_threshold"], main["elimination_mode"]) == ("DISCREPANCY", True, "min")
# у A по B три ненулевые строки: 62.01 40 и 200, 60.02 60; строка 0 в сумму не входит
assert (len(main["osv_entry_ids_a"]), len(main["osv_entry_ids_b"])) == (len(("40", "200", "60")), 1)
mirrored = by_triple[(6, b, a)]
assert (mirrored["status"], mirrored["eliminated_amount"]) == ("AUTO", "70.00")
assert by_triple[(6, a, c)]["status"] == "DISCREPANCY"
assert (by_triple[(1, a, b)]["status"], by_triple[(1, a, b)]["eliminated_amount"]) == ("MANUAL", "0.00")
# дебиторка № 6 входит и в № 7 (займы 67), встречной стороны нет — двойной учёт до AURORA-1751
one_sided = by_triple[(7, a, b)]
assert (one_sided["status"], one_sided["amount_b"], one_sided["deviation_percent"]) == ("ONE_SIDED", "0.00", None)
assert (3, a, b) not in by_triple # строк по счетам правила нет — тройки нет
assert [item["rule_no"] for item in totals.json()["items"]] == sorted(
item["rule_no"] for item in totals.json()["items"]
)
missing_totals = await client.get(f"/consolidations/versions/{uuid.uuid4()}/elimination-totals")
assert missing_totals.status_code == NOT_FOUND
async def test_all_excludes_off_balance_accounts_but_manual_can_select_them(
auth_client: AuthClientFactory, seeded: AsyncSession

View File

@ -140,18 +140,18 @@ def test_enum_values_and_sets_are_compared(dbml_file: Path) -> None:
def test_newer_dbml_version_fails_before_comparing_tables(tmp_path: Path, dbml_file: Path) -> None:
lines = dbml_file.read_text(encoding="utf-8").splitlines()
newer = tmp_path / "data-model-v5.23.dbml"
# шапка 5.22 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
newer.write_text(lines[0].replace("v5.22", "v5.23") + "\nTable broken {{{\n", encoding="utf-8")
assert run_check(newer) == (1, ["версия DBML 5.23 не совпадает с версией ORM 5.22: обновите модели"])
newer = tmp_path / "data-model-v5.24.dbml"
# шапка 5.23 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
newer.write_text(lines[0].replace("v5.23", "v5.24") + "\nTable broken {{{\n", encoding="utf-8")
assert run_check(newer) == (1, ["версия DBML 5.24 не совпадает с версией ORM 5.23: обновите модели"])
def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None:
newer = tmp_path / "data-model-v5.23.dbml"
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.22", "v5.23", 1), encoding="utf-8")
newer = tmp_path / "data-model-v5.24.dbml"
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.23", "v5.24", 1), encoding="utf-8")
result = _run_cli(str(newer))
assert result.returncode == 1
assert result.stdout == "версия DBML 5.23 не совпадает с версией ORM 5.22: обновите модели\n"
assert result.stdout == "версия DBML 5.24 не совпадает с версией ORM 5.23: обновите модели\n"
def test_header_without_version_is_usage_error(tmp_path: Path) -> None:

View File

@ -26,27 +26,19 @@ def _rule() -> SimpleNamespace:
("rule", "version_no", "expected"),
[
(None, 3, False),
(SimpleNamespace(version_no=3, valid_to=None), None, True),
(SimpleNamespace(version_no=3, valid_to="2026-12-31"), None, False),
(SimpleNamespace(version_no=3), 2, False),
(SimpleNamespace(version_no=3), 3, True),
(SimpleNamespace(version_no=3, valid_to="2026-12-31"), 3, True),
],
)
def test_rule_is_valid_for_version(rule: SimpleNamespace | None, version_no: int | None, expected: bool) -> None:
def test_rule_is_valid_for_version(rule: SimpleNamespace | None, version_no: int, expected: bool) -> None:
assert rules_repo.is_valid_for_version(rule, version_no) is expected
async def test_rules_filter_by_version_or_open_valid_to() -> None:
async def test_rules_filter_by_fixed_version() -> None:
version_no = 3
session = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(all=lambda: [])))
await rules_repo.list_rules_with_accounts(session, None)
query = str(session.execute.await_args.args[0])
assert "valid_to IS NULL" in query
assert "max(" in query
assert "version_no = (SELECT" in query
await rules_repo.list_rules_with_accounts(session, version_no)
assert "valid_to IS NULL" not in str(session.execute.await_args.args[0])
assert "version_no =" in str(session.execute.await_args.args[0])
@ -73,41 +65,3 @@ async def test_fixed_rules_use_version_number(monkeypatch: pytest.MonkeyPatch) -
assert [item.id for item in result.rules] == [rule.id]
assert result.rules[0].company_1.accounts == [account_id]
listed.assert_awaited_once_with(session, rule.version_no)
async def test_unfixed_rules_use_open_valid_to(monkeypatch: pytest.MonkeyPatch) -> None:
session, scope = object(), object()
version_id = uuid.uuid4()
rule = _rule()
monkeypatch.setattr(
service_module.consolidations_repo,
"get_version",
AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=None), None)),
)
listed = AsyncMock(return_value=[(rule, None)])
monkeypatch.setattr(service_module.rules_repo, "list_rules_with_accounts", listed)
result = await service_module.EliminationRuleService(session, scope).list_rules(version_id)
assert result.is_version_fixed is False
assert result.version_no == rule.version_no
assert [item.id for item in result.rules] == [rule.id]
listed.assert_awaited_once_with(session, None)
async def test_no_current_rules_returns_empty_with_unfixed_flag(monkeypatch: pytest.MonkeyPatch) -> None:
session, scope = object(), object()
monkeypatch.setattr(
service_module.consolidations_repo,
"get_version",
AsyncMock(return_value=(SimpleNamespace(elimination_rules_version=None), None)),
)
listed = AsyncMock(return_value=[])
monkeypatch.setattr(service_module.rules_repo, "list_rules_with_accounts", listed)
result = await service_module.EliminationRuleService(session, scope).list_rules(uuid.uuid4())
assert result.is_version_fixed is False
assert result.version_no is None
assert result.rules == []
listed.assert_awaited_once_with(session, None)

View File

@ -1,4 +1,4 @@
"""Enum'ы ORM против блоков `Enum {}` DBML v5.22: имена, значения и их порядок (без БД)."""
"""Enum'ы ORM против блоков `Enum {}` DBML v5.23: имена, значения и их порядок (без БД)."""
import enum
import re
@ -15,7 +15,7 @@ EXPECTED_ENUM_COUNT = 33
def test_dbml_version_format() -> None:
assert re.fullmatch(r"\d+\.\d+", DBML_VERSION)
assert DBML_VERSION == "5.22"
assert DBML_VERSION == "5.23"
assert f"v{DBML_VERSION}" in dbml_path().name

View File

@ -136,10 +136,6 @@ async def test_preset_applies_exclusions_to_its_own_side_and_rule_measure(monkey
assert captured[0].company_2_account_ids == {parent.id, child.id}
assert captured[0].measure == "turnover"
version.elimination_rules_version = None
monkeypatch.setattr(
matrix.elimination_rules_repo, "current_open_version_no", AsyncMock(return_value=rule.version_no)
)
_, info = await service.build_export(uuid.uuid4(), mode="PRESET", rule_id=uuid.uuid4())
assert info.mode == "PRESET"
assert info.debit_account_codes == ("76",)

View File

@ -39,7 +39,8 @@ from tests.test_models_part_07_09 import (
V5_19 = "0007_dbml_v5_19"
V5_20 = "0008_dbml_v5_20"
HEAD = "0009_dbml_v5_21"
V5_21 = "0009_dbml_v5_21"
HEAD = "0010_dbml_v5_23"
V5_18 = "0006_dbml_v5_18"
V5_17 = "0005_dbml_v5_17"
V5_16 = "0004_dbml_v5_16"
@ -586,6 +587,9 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N
),
ids,
)
await _insert_rule(
conn, version_no=1, valid_from=datetime.date(2020, 1, 1)
) # v5.23: версиям нужна версия правил
await alembic(scratch_db_url, "upgrade", "head")
async with engine.begin() as conn:
@ -1299,7 +1303,8 @@ def test_offline_sql_creates_schema_before_version_table() -> None:
(V5_18, V5_17),
(V5_19, V5_18),
(V5_20, V5_19),
(HEAD, V5_20),
(V5_21, V5_20),
(HEAD, V5_21),
):
assert (
f"UPDATE dkb.alembic_version SET version_num='{new_version}' "
@ -1366,3 +1371,121 @@ async def test_v5_20_downgrade_refuses_modified_document(scratch_db_url: str) ->
assert await conn.scalar(text("SELECT count(*) FROM dkb.document WHERE is_modified"))
finally:
await engine.dispose()
async def _insert_rule(
conn: AsyncConnection, *, version_no: int, valid_from: datetime.date, valid_to: datetime.date | None = None
) -> None:
await conn.execute(
text(
"INSERT INTO dkb.elimination_rule (id, statement_type, rule_no, article_1_line, article_2_line, side_1, "
"measure, elimination_mode, excise_adjust, version_no, valid_from, valid_to) "
"VALUES (gen_random_uuid(), 'balance', 6, '1230', '1520', 'D', 'closing', 'min', false, :no, "
":start, :end)"
),
{"no": version_no, "start": valid_from, "end": valid_to},
)
async def _insert_versions(conn: AsyncConnection, created: dict[str, datetime.datetime | None]) -> dict[str, uuid.UUID]:
"""Версии консолидации v5.21 без номера правил; ключ — метка, значение — `created_at` (None — пусто)."""
ids = {k: uuid.uuid4() for k in ("office", "group", "user", "c")}
await conn.execute(text("INSERT INTO dkb.regional_office (id, name) VALUES (:office, 'ГО')"), ids)
await conn.execute(
text(
"INSERT INTO dkb.company_group (id, code, name, base_regional_office_id, valid_from) "
"VALUES (:group, 'G-1', 'ГК', :office, now())"
),
ids,
)
await conn.execute(
text(
"INSERT INTO dkb.app_user (id, email, full_name, regional_office_id) VALUES (:user, 'm@x.ru', 'Т', :office)"
),
ids,
)
await conn.execute(
text(
"INSERT INTO dkb.consolidation (id, company_group_id, year, quarter, reporting_date) "
"VALUES (:c, :group, 2026, 'Q1', '2026-04-01')"
),
ids,
)
versions: dict[str, uuid.UUID] = {}
for no, (label, created_at) in enumerate(created.items(), start=1):
versions[label] = uuid.uuid4()
await conn.execute(
text(
"INSERT INTO dkb.consolidation_version (id, consolidation_id, version_no, status, scenario, approach, "
"goodwill_enabled, initiator_id, initiator_office_id, is_in_work, created_at) "
"VALUES (:id, :c, :no, 'INITIATED', 'CREATE', 'BASIC', false, :user, :office, true, "
":created)"
),
ids | {"id": versions[label], "no": no, "created": created_at},
)
return versions
@pytest.mark.integration
async def test_upgrade_0010_fills_rules_version_by_creation_date(scratch_db_url: str) -> None:
"""v5.23: номер правил — действующих на дату создания версии (по Москве), без даты — на сегодня; затем NOT NULL."""
engine = create_async_engine(scratch_db_url)
try:
await alembic(scratch_db_url, "upgrade", V5_21)
async with engine.begin() as conn:
await _insert_rule(
conn, version_no=1, valid_from=datetime.date(2020, 1, 1), valid_to=datetime.date(2026, 6, 30)
)
await _insert_rule(conn, version_no=2, valid_from=datetime.date(2026, 7, 1))
versions = await _insert_versions(
conn,
{
"spring": datetime.datetime(2026, 3, 1, 9, tzinfo=datetime.UTC),
# 30.06 21:30 UTC — уже 01.07 по Москве
"edge": datetime.datetime(2026, 6, 30, 21, 30, tzinfo=datetime.UTC),
"undated": None,
},
)
await alembic(scratch_db_url, "upgrade", "head")
async with engine.connect() as conn:
rows = dict(
(await conn.execute(text("SELECT id, elimination_rules_version FROM dkb.consolidation_version"))).all()
)
assert rows == {versions["spring"]: 1, versions["edge"]: 2, versions["undated"]: 2}
nullable = await _scalars(
conn,
"SELECT is_nullable FROM information_schema.columns WHERE table_schema='dkb' "
"AND table_name='consolidation_version' AND column_name='elimination_rules_version'",
)
assert nullable == ["NO"]
await alembic(scratch_db_url, "downgrade", V5_21)
async with engine.connect() as conn:
nullable = await _scalars(
conn,
"SELECT is_nullable FROM information_schema.columns WHERE table_schema='dkb' "
"AND table_name='consolidation_version' AND column_name='elimination_rules_version'",
)
assert nullable == ["YES"]
assert (
await conn.scalar(
text("SELECT count(*) FROM dkb.consolidation_version WHERE elimination_rules_version IS NULL")
)
== 0
)
finally:
await engine.dispose()
@pytest.mark.integration
async def test_upgrade_0010_refuses_versions_without_rules(scratch_db_url: str) -> None:
engine = create_async_engine(scratch_db_url)
try:
await alembic(scratch_db_url, "upgrade", V5_21)
async with engine.begin() as conn:
await _insert_versions(conn, {"lonely": datetime.datetime(2026, 3, 1, 9, tzinfo=datetime.UTC)})
with pytest.raises(AssertionError, match="миграция v5.23 невозможна"):
await alembic(scratch_db_url, "upgrade", "head")
async with engine.connect() as conn:
assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [V5_21]
finally:
await engine.dispose()

View File

@ -210,6 +210,7 @@ async def _check_active_lock_is_unique(session: AsyncSession) -> None:
version_no=1,
status=ConsolidationStatus.INITIATED,
scenario=ConsolidationScenario.CREATE,
elimination_rules_version=1,
initiator_id=user.id,
initiator_office_id=office.id,
)

View File

@ -79,6 +79,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
# шахматка ВГО и правила элиминации
"/api/v1/consolidations/versions/{version_id}/elimination-rules": {"get"},
"/api/v1/consolidations/versions/{version_id}/elimination-pairs": {"get"},
"/api/v1/consolidations/versions/{version_id}/elimination-totals": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all/export": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"},

View File

@ -73,6 +73,7 @@ async def test_list_periods_sorts_periods_and_versions_descending(db_session: As
version_no=2,
status=ConsolidationStatus.INITIATED,
scenario=first.scenario,
elimination_rules_version=first.elimination_rules_version,
initiator_id=user.id,
initiator_office_id=user.regional_office_id,
)