AURORA-1536 #175
@ -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`, как и
|
||||
|
||||
2
Makefile
2
Makefile
@ -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
|
||||
|
||||
|
||||
@ -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) — план срезов;
|
||||
|
||||
28
api/alembic/versions/0010_dbml_v5_23.py
Normal file
28
api/alembic/versions/0010_dbml_v5_23.py
Normal 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)
|
||||
@ -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 = "нет"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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):
|
||||
"""Выбранные дебетовые и кредитовые счета для ручного расчёта шахматки."""
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()),
|
||||
)
|
||||
|
||||
@ -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"):
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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",)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user