feat: DBML v5.23 — версия правил обязательна; список пар ВГО версии (ревью #175, AURORA-1536)

- consolidation_version.elimination_rules_version NOT NULL: миграция 0010
  проставляет существующим версиям номер правил, действовавших на дату
  создания (по Москве), иначе последних открытых; без правил — отказ;
  создание версии без действующих правил — 409 ELIMINATION_RULES_MISSING;
  ветки «правила не зафиксированы» убраны (rules, preset, is_version_fixed
  всегда true);
- GET …/elimination-totals: все тройки «правило ББ × упорядоченная пара
  периметра» с ΣA, ΣB, расхождением, суммой элиминации, статусом
  AUTO/DISCREPANCY/ONE_SIDED/MANUAL и osv_entry_ids; сводная ОСВ строится
  один раз; сбор строк сторон вынесен в _collect_rule_sides — общий
  с упаковкой тройки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-23 18:42:10 +04:00
parent 812e530fe5
commit b4522addc0
24 changed files with 397 additions and 136 deletions

View File

@ -175,7 +175,7 @@
- NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. - 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. - Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring.
- Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц. - Вычисляемые объекты консолидации — `api/src/domain/`, без ORM-таблиц.
- Фронт (`web/`) разрабатывается здесь (с 21.10.2026): фронтовые задачи — worktree arm-core и PR в `main`, как и - Фронт (`web/`) разрабатывается здесь (с 21.10.2026): фронтовые задачи — worktree arm-core и PR в `main`, как и

View File

@ -3,7 +3,7 @@ VENV := api/.venv
BIN := $(CURDIR)/$(VENV)/bin BIN := $(CURDIR)/$(VENV)/bin
COMPOSE_FILE ?= ../docs/dev/docker-compose.yml COMPOSE_FILE ?= ../docs/dev/docker-compose.yml
# DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/ # 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 .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` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен |
| `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) | | `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) |
| `make e2e` | сквозной прогон загрузки ОСВ: `pytest -m e2e`, нужны compose и tabular-parser из профиля `services` (без него — skip) | | `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 migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate |
| `make check` | быстрая проверка без Docker: `lint` + `test` | | `make check` | быстрая проверка без Docker: `lint` + `test` |
| `make docker-build` | сборка образа: `docker build -t arm-core .` | | `make docker-build` | сборка образа: `docker build -t arm-core .` |
@ -1179,7 +1179,7 @@ queue group `arm-core`, подписчики группы получают со
### Проверка ORM и DBML ### Проверка ORM и DBML
Модель данных — `../docs/model/data-model-v5.22.dbml`. ORM обязана совпадать с ней один в один: Модель данных — `../docs/model/data-model-v5.23.dbml`. ORM обязана совпадать с ней один в один:
```bash ```bash
make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно
@ -1202,7 +1202,7 @@ make migration-cycle # проверить цикл upgr
``` ```
api/ api/
run_server.py точка входа (uvicorn; хост и порт — из настроек) 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) rules/ statement_lines.yaml — справочник имён строк форм ББ/ОФР по редакциям (словарь проверок §7 — в пакете arm_statements)
seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист) seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист)
seed/dev/ dev-данные: филиалы и пользователи по ролям seed/dev/ dev-данные: филиалы и пользователи по ролям
@ -1286,7 +1286,7 @@ Dockerfile только для локального compose
- [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы; - [`../docs/dev/README.md`](../docs/dev/README.md) — окружение разработки (compose), порты, типичные проблемы;
- [`../docs/context/03-architecture.md`](../docs/context/03-architecture.md) — архитектура, соглашения шаблона, NATS и S3; - [`../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/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/05-domain-rules.md`](../docs/context/05-domain-rules.md) — алгоритмы предметной области;
- [`../docs/context/07-open-questions.md`](../docs/context/07-open-questions.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) — план срезов; - [`../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, загруженную с этим значением. #: Эталонная схема модели. Имя схемы в БД конфигурируемо (`DB_SCHEMA`), CLI сверяет ORM, загруженную с этим значением.
SCHEMA = "dkb" SCHEMA = "dkb"
DBML_FILE_NAME = "data-model-v5.22.dbml" DBML_FILE_NAME = "data-model-v5.23.dbml"
ORM = "ORM" ORM = "ORM"
DBML = "DBML" DBML = "DBML"
ABSENT = "нет" ABSENT = "нет"

View File

@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, status
from src.api.dependencies.auth import require from src.api.dependencies.auth import require
from src.api.dependencies.consolidations import EliminationRuleServiceDep, IntercompanyMatrixServiceDep from src.api.dependencies.consolidations import EliminationRuleServiceDep, IntercompanyMatrixServiceDep
from src.schemas.common import ErrorDetail 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") router = APIRouter(prefix="/consolidations/versions")
@ -37,6 +37,21 @@ async def list_rules(
return await service.list_rules(version_id) 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( @router.get(
"/{version_id}/elimination-pairs", "/{version_id}/elimination-pairs",
response_model=EliminationPairsOut, 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). Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic).
""" """
@ -7,7 +7,7 @@ from . import access, accounting, audit_ex, consolidation, directories, files, k
from .base import Base, UUIDPrimaryMixin from .base import Base, UUIDPrimaryMixin
#: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm). #: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm).
DBML_VERSION = "5.22" DBML_VERSION = "5.23"
__all__ = [ __all__ = [
"DBML_VERSION", "DBML_VERSION",

View File

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

View File

@ -11,21 +11,13 @@ from src.models.accounting import ChartOfAccount, EliminationRule, EliminationRu
async def list_rules_with_accounts( async def list_rules_with_accounts(
session: AsyncSession, version_no: int | None session: AsyncSession, version_no: int
) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]: ) -> list[tuple[EliminationRule, EliminationRuleAccount | None]]:
"""Правила §4 заданной либо последней открытой версии.""" """Правила §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())
)
)
stmt = ( stmt = (
select(EliminationRule, EliminationRuleAccount) select(EliminationRule, EliminationRuleAccount)
.outerjoin(EliminationRuleAccount, EliminationRuleAccount.rule_id == EliminationRule.id) .outerjoin(EliminationRuleAccount, EliminationRuleAccount.rule_id == EliminationRule.id)
.where(condition) .where(EliminationRule.version_no == version_no)
.order_by( .order_by(
EliminationRule.statement_type, EliminationRule.statement_type,
EliminationRule.rule_no, 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()] 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: async def latest_effective_version_no(session: AsyncSession, as_of: date) -> int | None:
"""Максимальный номер версии правил, действующей на дату создания""" """Максимальный номер версии правил, действующей на дату создания"""
return await session.scalar( 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: def is_valid_for_version(rule: EliminationRule | None, version_no: int) -> bool:
"""Проверить версию правила §4 либо отсутствие `valid_to`.""" """Правило §4 из зафиксированной версии правил версии консолидации."""
return rule is not None and (rule.version_no == version_no if version_no is not None else rule.valid_to is None) return rule is not None and rule.version_no == version_no
async def list_rules(session: AsyncSession, version_no: int) -> list[EliminationRule]: async def list_rules(session: AsyncSession, version_no: int) -> list[EliminationRule]:

View File

@ -388,6 +388,41 @@ class EliminationPairsOut(BaseModel):
unpaired: list[EliminationUnpairedRowOut] 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): class IntercompanyMatrixManualIn(BaseModel):
"""Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки.""" """Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки."""

View File

@ -332,6 +332,10 @@ class ConsolidationService:
"""Записывает консолидацию, версию №1, периметр и первую запись истории; подхватывает документы.""" """Записывает консолидацию, версию №1, периметр и первую запись истории; подхватывает документы."""
now = utc_now() now = utc_now()
user = self._principal.user 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( consolidation = Consolidation(
company_group_id=group_id, company_group_id=group_id,
year=data.year, year=data.year,
@ -348,9 +352,7 @@ class ConsolidationService:
scenario=ConsolidationScenario.CREATE, scenario=ConsolidationScenario.CREATE,
approach=ConsolidationApproach.BASIC, approach=ConsolidationApproach.BASIC,
goodwill_enabled=False, goodwill_enabled=False,
elimination_rules_version=await elimination_rules_repo.latest_effective_version_no( elimination_rules_version=rules_version,
self._session, moscow_date(now)
),
initiator_id=user.id, initiator_id=user.id,
initiator_office_id=user.regional_office_id, initiator_office_id=user.regional_office_id,
is_in_work=True, is_in_work=True,

View File

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

View File

@ -31,6 +31,9 @@ from src.schemas.consolidations import (
EliminationMatchedPairOut, EliminationMatchedPairOut,
EliminationPairRowOut, EliminationPairRowOut,
EliminationPairsOut, EliminationPairsOut,
EliminationTotalItemOut,
EliminationTotalsOut,
EliminationTotalStatus,
EliminationUnpairedRowOut, EliminationUnpairedRowOut,
IntercompanyMatrixCellOut, IntercompanyMatrixCellOut,
IntercompanyMatrixCompanyOut, IntercompanyMatrixCompanyOut,
@ -402,12 +405,23 @@ def _matched_pair_out(
) )
def _pack_preset_rows( @dataclass(frozen=True)
rows: Sequence[ConsolidatedRow], class RuleSides:
selection: MatrixSelection, """Встречные строки тройки: исходные строки свода и строки сторон для сумм и упаковки."""
context: PairContext,
) -> RulePacking: 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 rule = context.rule
company_a_id = context.company_a_id company_a_id = context.company_a_id
company_b_id = context.company_b_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)) debit_rows.append(PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, debit_amount))
if in_credit and credit_amount > ZERO: if in_credit and credit_amount > ZERO:
credit_rows.append(PackRow(row.entry_ids, row.legal_entity_id, row.counterparty_id, credit_amount)) 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_a = sum((row.amount for row in rows_a), ZERO)
amount_b = sum((row.amount for row in rows_b), 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) packed = pack_rule_rows(debit_rows, credit_rows)
matched: list[EliminationMatchedPairOut] = [] matched: list[EliminationMatchedPairOut] = []
with_discrepancy: list[EliminationMatchedPairOut] = [] with_discrepancy: list[EliminationMatchedPairOut] = []
@ -511,6 +586,22 @@ class IntercompanyMatrixService:
unpaired=packing.unpaired, 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( async def build(
self, self,
version_id: uuid.UUID, version_id: uuid.UUID,
@ -637,10 +728,6 @@ class IntercompanyMatrixService:
rule, rule_accounts = await elimination_rules_repo.rule_with_account(self._session, rule_id) 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): if rule is None or not elimination_rules_repo.is_valid_for_version(rule, version.elimination_rules_version):
raise NotFoundError("правило-пресет не найдено для версии консолидации") 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: if rule.statement_type != StatementType.balance:
raise DomainValidationError("INVALID_RULE", "Шахматка PRESET поддерживает только правила ББ") raise DomainValidationError("INVALID_RULE", "Шахматка PRESET поддерживает только правила ББ")
if rule.side_1 not in ("D", "K") or rule.measure not in ("closing", "turnover"): 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, scenario=ConsolidationScenario.CREATE,
approach=ConsolidationApproach.BASIC, approach=ConsolidationApproach.BASIC,
goodwill_enabled=False, goodwill_enabled=False,
elimination_rules_version=1, # seed правил §4 — версия v1; с DBML v5.23 поле обязательно
initiator_id=initiator.id, initiator_id=initiator.id,
initiator_office_id=initiator.regional_office_id, initiator_office_id=initiator.regional_office_id,
is_in_work=status not in FINISHED_STATUSES, 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 @pytest.mark.integration
async def test_create_fixes_rules_for_creation_date_not_reporting_period( async def test_create_fixes_rules_for_creation_date_not_reporting_period(
auth_client: AuthClientFactory, seeded: AsyncSession 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" assert response.json()["detail"]["code"] == "INVALID_RULE"
async def test_preset_uses_current_rules_when_version_unfixed( async def test_preset_uses_fixed_rules_version(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
world = await _world(seeded) world = await _world(seeded)
rule = ( rule = (
await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO)) await seeded.scalars(select(EliminationRule).where(EliminationRule.rule_no == RECEIVABLES_PAYABLES_RULE_NO))
).one() ).one()
assert world.version.elimination_rules_version == rule.version_no
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}" url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{rule.id}"
listed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules") listed = await client.get(f"/consolidations/versions/{world.version.id}/elimination-rules")
assert listed.status_code == OK, listed.text 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 listed.json()["version_no"] == rule.version_no
assert "as_of" not in listed.json() assert "as_of" not in listed.json()
assert str(rule.id) in {item["id"] for item in listed.json()["rules"]} 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 pairs.status_code == OK, pairs.text
assert world.version.elimination_rules_version is None # GET не фиксирует номер правил
response = await client.get(url) response = await client.get(url)
assert response.status_code == OK, response.text assert response.status_code == OK, response.text
assert (await client.get(f"{url}/export")).status_code == OK assert (await client.get(f"{url}/export")).status_code == OK
audit = (await seeded.scalars(select(AuditEvent).where(AuditEvent.action == AuditAction.EXPORT))).one() 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_no"] == rule.rule_no
assert audit.after_value["rule_version_no"] == rule.version_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 world.version.elimination_rules_version = rule.version_no + 1
await seeded.flush() await seeded.flush()
assert (await client.get(url)).status_code == NOT_FOUND assert (await client.get(url)).status_code == NOT_FOUND
assert (await client.get(f"{url}/export")).status_code == NOT_FOUND assert (await client.get(f"{url}/export")).status_code == NOT_FOUND
world.version.elimination_rules_version = rule.version_no world.version.elimination_rules_version = rule.version_no
await seeded.flush() 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_url = f"/consolidations/versions/{world.version.id}/intercompany-matrix/preset/{uuid.uuid4()}"
missing = await client.get(missing_url) missing = await client.get(missing_url)
assert missing.status_code == NOT_FOUND assert missing.status_code == NOT_FOUND
assert (await client.get(f"{missing_url}/export")).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) world = await _world(seeded)
old_rule, new_rule = ( old_rule, new_rule = (
await seeded.scalars(select(EliminationRule).order_by(EliminationRule.rule_no).limit(2)) await seeded.scalars(select(EliminationRule).order_by(EliminationRule.rule_no).limit(2))
).all() ).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.rule_no = old_rule.rule_no
new_rule.version_no = old_rule.version_no + 1 new_rule.version_no = old_rule.version_no + 1
await seeded.flush() 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") response = await client.get(f"{base}/elimination-rules")
assert response.status_code == OK, response.text assert response.status_code == OK, response.text
payload = response.json() payload = response.json()
assert payload["is_version_fixed"] is False assert payload["is_version_fixed"] is True
assert payload["version_no"] == new_rule.version_no assert payload["version_no"] == old_rule.version_no
assert [item["id"] for item in payload["rules"]] == [str(new_rule.id)] assert str(new_rule.id) not in {item["id"] for item in payload["rules"]}
assert payload["rules"][0]["rule_no"] == old_rule.rule_no 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}") new_preset = await client.get(f"{base}/intercompany-matrix/preset/{new_rule.id}")
assert old_preset.status_code == NOT_FOUND assert new_preset.status_code == NOT_FOUND
async def test_elimination_pairs_use_preset_account_selection( # noqa: PLR0915 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) missing = await client.get(f"/consolidations/versions/{uuid.uuid4()}/elimination-pairs", params=params)
assert missing.status_code == NOT_FOUND 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( async def test_all_excludes_off_balance_accounts_but_manual_can_select_them(
auth_client: AuthClientFactory, seeded: AsyncSession 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: 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() lines = dbml_file.read_text(encoding="utf-8").splitlines()
newer = tmp_path / "data-model-v5.23.dbml" newer = tmp_path / "data-model-v5.24.dbml"
# шапка 5.22 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора # шапка 5.23 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
newer.write_text(lines[0].replace("v5.22", "v5.23") + "\nTable broken {{{\n", encoding="utf-8") newer.write_text(lines[0].replace("v5.23", "v5.24") + "\nTable broken {{{\n", encoding="utf-8")
assert run_check(newer) == (1, ["версия DBML 5.23 не совпадает с версией ORM 5.22: обновите модели"]) 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: def test_newer_dbml_version_via_cli(tmp_path: Path, dbml_file: Path) -> None:
newer = tmp_path / "data-model-v5.23.dbml" newer = tmp_path / "data-model-v5.24.dbml"
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.22", "v5.23", 1), encoding="utf-8") newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.23", "v5.24", 1), encoding="utf-8")
result = _run_cli(str(newer)) result = _run_cli(str(newer))
assert result.returncode == 1 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: 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"), ("rule", "version_no", "expected"),
[ [
(None, 3, False), (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), 2, False),
(SimpleNamespace(version_no=3), 3, True), (SimpleNamespace(version_no=3), 3, True),
(SimpleNamespace(version_no=3, valid_to="2026-12-31"), 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 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 version_no = 3
session = SimpleNamespace(execute=AsyncMock(return_value=SimpleNamespace(all=lambda: []))) 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) 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 "valid_to IS NULL" not in str(session.execute.await_args.args[0])
assert "version_no =" 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 [item.id for item in result.rules] == [rule.id]
assert result.rules[0].company_1.accounts == [account_id] assert result.rules[0].company_1.accounts == [account_id]
listed.assert_awaited_once_with(session, rule.version_no) 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 enum
import re import re
@ -15,7 +15,7 @@ EXPECTED_ENUM_COUNT = 33
def test_dbml_version_format() -> None: def test_dbml_version_format() -> None:
assert re.fullmatch(r"\d+\.\d+", DBML_VERSION) 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 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].company_2_account_ids == {parent.id, child.id}
assert captured[0].measure == "turnover" 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()) _, info = await service.build_export(uuid.uuid4(), mode="PRESET", rule_id=uuid.uuid4())
assert info.mode == "PRESET" assert info.mode == "PRESET"
assert info.debit_account_codes == ("76",) 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_19 = "0007_dbml_v5_19"
V5_20 = "0008_dbml_v5_20" 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_18 = "0006_dbml_v5_18"
V5_17 = "0005_dbml_v5_17" V5_17 = "0005_dbml_v5_17"
V5_16 = "0004_dbml_v5_16" V5_16 = "0004_dbml_v5_16"
@ -586,6 +587,9 @@ async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> N
), ),
ids, ids,
) )
await _insert_rule(
conn, version_no=1, valid_from=datetime.date(2020, 1, 1)
) # v5.23: версиям нужна версия правил
await alembic(scratch_db_url, "upgrade", "head") await alembic(scratch_db_url, "upgrade", "head")
async with engine.begin() as conn: 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_18, V5_17),
(V5_19, V5_18), (V5_19, V5_18),
(V5_20, V5_19), (V5_20, V5_19),
(HEAD, V5_20), (V5_21, V5_20),
(HEAD, V5_21),
): ):
assert ( assert (
f"UPDATE dkb.alembic_version SET version_num='{new_version}' " 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")) assert await conn.scalar(text("SELECT count(*) FROM dkb.document WHERE is_modified"))
finally: finally:
await engine.dispose() 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, version_no=1,
status=ConsolidationStatus.INITIATED, status=ConsolidationStatus.INITIATED,
scenario=ConsolidationScenario.CREATE, scenario=ConsolidationScenario.CREATE,
elimination_rules_version=1,
initiator_id=user.id, initiator_id=user.id,
initiator_office_id=office.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-rules": {"get"},
"/api/v1/consolidations/versions/{version_id}/elimination-pairs": {"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": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all/export": {"get"}, "/api/v1/consolidations/versions/{version_id}/intercompany-matrix/all/export": {"get"},
"/api/v1/consolidations/versions/{version_id}/intercompany-matrix/manual": {"post"}, "/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, version_no=2,
status=ConsolidationStatus.INITIATED, status=ConsolidationStatus.INITIATED,
scenario=first.scenario, scenario=first.scenario,
elimination_rules_version=first.elimination_rules_version,
initiator_id=user.id, initiator_id=user.id,
initiator_office_id=user.regional_office_id, initiator_office_id=user.regional_office_id,
) )