model: DBML v5.18 — stored_file.consolidation_id, миграция 0006 с backfill (#96)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-10 01:03:26 +04:00
parent 45e761bd23
commit d5d09d0b5c
12 changed files with 233 additions and 25 deletions

View File

@ -159,7 +159,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.17.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17`); `api/scripts/check_dbml_vs_orm.py` (`make dbml-check`, входит в `check-full`) — обязательная проверка. - ORM 1:1 с `../docs/model/data-model-v5.18.dbml` (текущая версия; миграции `0001_dbml_v5_13` + `0002_dbml_v5_14` + `0003_dbml_v5_15` + `0004_dbml_v5_16` + `0005_dbml_v5_17` + `0006_dbml_v5_18`); `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-таблиц.
- Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR. - Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR.

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.17.dbml DBML ?= ../../docs/model/data-model-v5.18.dbml
.PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-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 check-full up migrate seed seed-dev

View File

@ -952,7 +952,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.17.dbml` | | `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.18.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 .` |
@ -1004,7 +1004,7 @@ queue group `arm-core`, подписчики группы получают со
### Проверка ORM и DBML ### Проверка ORM и DBML
Модель данных — `../docs/model/data-model-v5.17.dbml`. ORM обязана совпадать с ней один в один: Модель данных — `../docs/model/data-model-v5.18.dbml`. ORM обязана совпадать с ней один в один:
```bash ```bash
make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно make dbml-check # пустой вывод и код 0 — совпадение; расхождения печатаются построчно
@ -1027,7 +1027,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) 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)
rules/ statement_checks.yaml — словарь проверок §7 формы ББ/ОФР (копия словаря tabular-parser; меняется в обоих репозиториях) rules/ statement_checks.yaml — словарь проверок §7 формы ББ/ОФР (копия словаря tabular-parser; меняется в обоих репозиториях)
seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист) seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист)
seed/dev/ dev-данные: филиалы и пользователи по ролям seed/dev/ dev-данные: филиалы и пользователи по ролям
@ -1111,7 +1111,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.17.dbml`](../docs/model/data-model-v5.17.dbml) — модель данных; [`../docs/model/data-model-v5.18.dbml`](../docs/model/data-model-v5.18.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,52 @@
"""DBML v5.18: stored_file.consolidation_id — консолидация (период ГК), в контексте которой загружен файл
(RSHB/arm-ka-docs#90, RSHB/arm-core#96; design D-1, D-2).
Колонка nullable, FK → consolidation, последняя в таблице; индекс (legal_entity_id, consolidation_id) — рабочий стол
консоли для ЮЛ. Backfill: файлу с документом, привязанным к версии (consolidation_document), — консолидация этой
версии; при привязках к версиям разных консолидаций — самый ранний период (год, затем квартал; enum period_quarter
упорядочен Q1 < Q2 < Q3 < Q4 по объявлению); файлы без привязок остаются NULL.
Revision ID: 0006_dbml_v5_18
Revises: 0005_dbml_v5_17
Create Date: 2026-10-09
"""
from collections.abc import Sequence
from alembic import op
from src.models.base import SCHEMA
revision: str = "0006_dbml_v5_18"
down_revision: str | Sequence[str] | None = "0005_dbml_v5_17"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
INDEX = "ix_stored_file_legal_entity_id_consolidation_id"
FK = "fk_stored_file_consolidation_id_consolidation"
def upgrade() -> None:
op.execute(
f"ALTER TABLE {SCHEMA}.stored_file ADD COLUMN consolidation_id uuid "
f"CONSTRAINT {FK} REFERENCES {SCHEMA}.consolidation (id)"
)
op.execute(f"CREATE INDEX {INDEX} ON {SCHEMA}.stored_file (legal_entity_id, consolidation_id)")
op.execute(
f"""
UPDATE {SCHEMA}.stored_file AS sf SET consolidation_id = pick.consolidation_id
FROM (
SELECT DISTINCT ON (d.stored_file_id) d.stored_file_id, c.id AS consolidation_id
FROM {SCHEMA}.document d
JOIN {SCHEMA}.consolidation_document cd ON cd.document_id = d.id
JOIN {SCHEMA}.consolidation_version v ON v.id = cd.consolidation_version_id
JOIN {SCHEMA}.consolidation c ON c.id = v.consolidation_id
ORDER BY d.stored_file_id, c.year, c.quarter, c.id
) AS pick
WHERE sf.id = pick.stored_file_id
"""
)
def downgrade() -> None:
op.execute(f"DROP INDEX {SCHEMA}.{INDEX}")
op.execute(f"ALTER TABLE {SCHEMA}.stored_file DROP COLUMN consolidation_id")

View File

@ -2,7 +2,7 @@
Запуск из `api/`: Запуск из `api/`:
python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.17.dbml python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.18.dbml
Без аргумента DBML ищется в `$ARM_KA_DOCS/model/` или в соседнем `docs/` рядом с `arm-core`. Без аргумента DBML ищется в `$ARM_KA_DOCS/model/` или в соседнем `docs/` рядом с `arm-core`.

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.17.dbml" DBML_FILE_NAME = "data-model-v5.18.dbml"
ORM = "ORM" ORM = "ORM"
DBML = "DBML" DBML = "DBML"
ABSENT = "нет" ABSENT = "нет"

View File

@ -1,4 +1,4 @@
"""ORM-модели. Источник истины — docs/model/data-model-v5.17.dbml; заполняется в Срезе 0. """ORM-модели. Источник истины — docs/model/data-model-v5.18.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.17" DBML_VERSION = "5.18"
__all__ = [ __all__ = [
"DBML_VERSION", "DBML_VERSION",

View File

@ -1,6 +1,6 @@
"""Enum'ы модели данных DBML v5.17 (28 штук) и фабрика `pg_enum` для PostgreSQL-колонок. """Enum'ы модели данных DBML v5.17 (28 штук) и фабрика `pg_enum` для PostgreSQL-колонок.
Источник истины — docs/model/data-model-v5.17.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке Источник истины — docs/model/data-model-v5.18.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке
DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются. DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются.
Имя члена совпадает со значением (все значения DBML — валидные python-идентификаторы, кириллица допустима). Имя члена совпадает со значением (все значения DBML — валидные python-идентификаторы, кириллица допустима).
Классы — `enum.StrEnum` (подкласс `str`) — удобно для pydantic/JSON; `values_callable` в `pg_enum` в БД пишет `.value`. Классы — `enum.StrEnum` (подкласс `str`) — удобно для pydantic/JSON; `values_callable` в `pg_enum` в БД пишет `.value`.

View File

@ -55,6 +55,8 @@ class StoredFile(UUIDPrimaryMixin, Base):
Index("ix_stored_file_legal_entity_id_status", "legal_entity_id", "status"), Index("ix_stored_file_legal_entity_id_status", "legal_entity_id", "status"),
# v5.15 п.4 (OQ-117): проверка FK при удалении recognition_job / файла # v5.15 п.4 (OQ-117): проверка FK при удалении recognition_job / файла
Index("ix_stored_file_current_recognition_job_id", "current_recognition_job_id"), Index("ix_stored_file_current_recognition_job_id", "current_recognition_job_id"),
# v5.18: рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»)
Index("ix_stored_file_legal_entity_id_consolidation_id", "legal_entity_id", "consolidation_id"),
) )
legal_entity_id: Mapped[uuid.UUID] = mapped_column( legal_entity_id: Mapped[uuid.UUID] = mapped_column(
@ -79,6 +81,9 @@ class StoredFile(UUIDPrimaryMixin, Base):
uploaded_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) uploaded_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id"))
uploaded_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) uploaded_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
# v5.18: консолидация (период ГК), в контексте которой файл загружен; NULL — без контекста (§9.5 п. 8). Последняя
# колонка: миграция 0006 добавляет её ALTER TABLE, а check_dbml сверяет порядок
consolidation_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("consolidation.id"))
class Document(UUIDPrimaryMixin, Base): class Document(UUIDPrimaryMixin, Base):

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.18.dbml" newer = tmp_path / "data-model-v5.19.dbml"
# шапка 5.17 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора # шапка 5.18 + заведомо сломанное тело: если бы таблицы сравнивались, были бы другие строки/ошибка разбора
newer.write_text(lines[0].replace("v5.17", "v5.18") + "\nTable broken {{{\n", encoding="utf-8") newer.write_text(lines[0].replace("v5.18", "v5.19") + "\nTable broken {{{\n", encoding="utf-8")
assert run_check(newer) == (1, ["версия DBML 5.18 не совпадает с версией ORM 5.17: обновите модели"]) assert run_check(newer) == (1, ["версия DBML 5.19 не совпадает с версией ORM 5.18: обновите модели"])
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.18.dbml" newer = tmp_path / "data-model-v5.19.dbml"
newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.17", "v5.18", 1), encoding="utf-8") newer.write_text(dbml_file.read_text(encoding="utf-8").replace("v5.18", "v5.19", 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.18 не совпадает с версией ORM 5.17: обновите модели\n" assert result.stdout == "версия DBML 5.19 не совпадает с версией ORM 5.18: обновите модели\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

@ -1,4 +1,4 @@
"""Enum'ы ORM против блоков `Enum {}` DBML v5.17: имена, значения и их порядок (без БД).""" """Enum'ы ORM против блоков `Enum {}` DBML v5.18: имена, значения и их порядок (без БД)."""
import enum import enum
import re import re
@ -15,7 +15,7 @@ EXPECTED_ENUM_COUNT = 28
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.17" assert DBML_VERSION == "5.18"
assert f"v{DBML_VERSION}" in dbml_path().name assert f"v{DBML_VERSION}" in dbml_path().name

View File

@ -1,5 +1,5 @@
"""Миграции 0001 (DBML v5.13), 0002 (v5.14), 0003 (v5.15), 0004 (v5.16), 0005 (v5.17) на PostgreSQL 16: полный цикл, """Миграции 0001 (DBML v5.13), 0002 (v5.14), 0003 (v5.15), 0004 (v5.16), 0005 (v5.17), 0006 (v5.18) на PostgreSQL 16:
откат на шаг, пустой autogenerate, схема = схеме ORM. полный цикл, откат на шаг, пустой autogenerate, схема = схеме ORM.
Spec data-model, требование «Миграции применяются с нуля и откатываются» (сценарии «Полный цикл» и «Автогенерация Spec data-model, требование «Миграции применяются с нуля и откатываются» (сценарии «Полный цикл» и «Автогенерация
ничего не находит»), design D-2. Alembic запускается отдельным процессом (`python -m alembic -x dburl=...`), как из ничего не находит»), design D-2. Alembic запускается отдельным процессом (`python -m alembic -x dburl=...`), как из
@ -36,7 +36,8 @@ from tests.test_models_part_07_09 import (
_check_active_lock_is_unique, _check_active_lock_is_unique,
) )
HEAD = "0005_dbml_v5_17" HEAD = "0006_dbml_v5_18"
V5_17 = "0005_dbml_v5_17"
V5_16 = "0004_dbml_v5_16" V5_16 = "0004_dbml_v5_16"
V5_15 = "0003_dbml_v5_15" V5_15 = "0003_dbml_v5_15"
V5_14 = "0002_dbml_v5_14" V5_14 = "0002_dbml_v5_14"
@ -451,6 +452,156 @@ async def _kpp_columns(conn: AsyncConnection) -> list[Any]:
) )
async def _consolidation_id_column(conn: AsyncConnection) -> list[Any]:
return (
await conn.execute(
text(
"SELECT is_nullable, data_type FROM information_schema.columns "
"WHERE table_schema = 'dkb' AND table_name = 'stored_file' AND column_name = 'consolidation_id'"
)
)
).all()
@pytest.mark.integration
async def test_upgrade_0006_backfills_consolidation_id(scratch_db_url: str) -> None:
"""0006: `stored_file.consolidation_id uuid` nullable, последняя колонка, индекс
`(legal_entity_id, consolidation_id)`; backfill по `consolidation_document`: файл A привязан к версиям 2025 Q1 и
2024 Q4 → 2024 Q4 (самый ранний период, год важнее квартала); файл B без привязок → NULL; `downgrade 0005`
убирает индекс и колонку, повторный upgrade возвращает."""
engine = create_async_engine(scratch_db_url)
now = datetime.datetime.now(datetime.UTC)
ids = {
name: uuid.uuid4()
for name in (
"office",
"group",
"entity",
"user",
"s3a",
"s3b",
"file_a",
"file_b",
"doc_a",
"c_2025q1",
"c_2024q4",
"v_2025q1",
"v_2024q4",
)
}
try:
await alembic(scratch_db_url, "upgrade", V5_17)
async with engine.begin() as conn:
assert await _consolidation_id_column(conn) == []
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 | {"now": now},
)
await conn.execute(
text(
"INSERT INTO dkb.legal_entity (id, inn, name, company_group_id, valid_from) "
"VALUES (:entity, '7701000001', 'ООО', :group, :now)"
),
ids | {"now": now},
)
await conn.execute(
text(
"INSERT INTO dkb.app_user (id, email, full_name, regional_office_id) "
"VALUES (:user, 'm@x.ru', 'Тест', :office)"
),
ids,
)
for s3, file in (("s3a", "file_a"), ("s3b", "file_b")):
await conn.execute(
text(
"INSERT INTO dkb.s3_object (id, bucket_role, bucket, key, created_at) "
f"VALUES (:{s3}, 'uploads', 'b', 'k-{s3}', :now)"
),
ids | {"now": now},
)
await conn.execute(
text(
"INSERT INTO dkb.stored_file (id, legal_entity_id, file_type, s3_object_id, status, "
f"version_no) VALUES (:{file}, :entity, 'XLS', :{s3}, 'PROCESSED', 1)"
),
ids,
)
await conn.execute(
text(
"INSERT INTO dkb.document (id, stored_file_id, legal_entity_id, document_type, osv_account_code, "
"period_year, period_quarter, status) "
"VALUES (:doc_a, :file_a, :entity, 'OSV', '60', 2024, 'Q4', 'CONFIRMED')"
),
ids,
)
for c, v, year, quarter, rd in (
("c_2025q1", "v_2025q1", 2025, "Q1", "2025-04-01"),
("c_2024q4", "v_2024q4", 2024, "Q4", "2025-01-01"),
):
await conn.execute(
text(
"INSERT INTO dkb.consolidation (id, company_group_id, year, quarter, reporting_date) "
f"VALUES (:{c}, :group, {year}, '{quarter}', '{rd}')"
),
ids,
)
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) "
f"VALUES (:{v}, :{c}, 1, 'INITIATED', 'CREATE', 'BASIC', false, :user, :office, true)"
),
ids,
)
await conn.execute(
text(
"INSERT INTO dkb.consolidation_document (id, consolidation_version_id, document_id) "
f"VALUES (gen_random_uuid(), :{v}, :doc_a)"
),
ids,
)
await alembic(scratch_db_url, "upgrade", "head")
async with engine.begin() as conn:
assert await _consolidation_id_column(conn) == [("YES", "uuid")]
last = await _scalars(
conn,
"SELECT column_name FROM information_schema.columns WHERE table_schema = 'dkb' "
"AND table_name = 'stored_file' ORDER BY ordinal_position DESC LIMIT 1",
)
assert last == ["consolidation_id"]
rows = (await conn.execute(text("SELECT id, consolidation_id FROM dkb.stored_file ORDER BY id"))).all()
assert dict(rows) == {ids["file_a"]: ids["c_2024q4"], ids["file_b"]: None}
assert await _scalars(
conn,
"SELECT indexname FROM pg_indexes WHERE schemaname = 'dkb' "
"AND indexname = 'ix_stored_file_legal_entity_id_consolidation_id'",
) == ["ix_stored_file_legal_entity_id_consolidation_id"]
await engine.dispose()
await alembic(scratch_db_url, "downgrade", V5_17)
async with engine.connect() as conn:
assert await _consolidation_id_column(conn) == []
assert (
await _scalars(
conn,
"SELECT indexname FROM pg_indexes WHERE schemaname = 'dkb' "
"AND indexname = 'ix_stored_file_legal_entity_id_consolidation_id'",
)
== []
)
await engine.dispose()
await alembic(scratch_db_url, "upgrade", "head")
async with engine.connect() as conn:
assert await _consolidation_id_column(conn) == [("YES", "uuid")]
finally:
await engine.dispose()
@pytest.mark.integration @pytest.mark.integration
async def test_upgrade_0005_adds_nullable_kpp_columns(scratch_db_url: str) -> None: async def test_upgrade_0005_adds_nullable_kpp_columns(scratch_db_url: str) -> None:
"""0005: `kpp varchar` nullable у `legal_entity` и `legal_entity_history`, существующие ЮЛ — с `kpp IS NULL`; """0005: `kpp varchar` nullable у `legal_entity` и `legal_entity_history`, существующие ЮЛ — с `kpp IS NULL`;
@ -714,8 +865,8 @@ def test_offline_sql_creates_schema_before_version_table() -> None:
assert schema_at < output.index("CREATE TABLE dkb.alembic_version") assert schema_at < output.index("CREATE TABLE dkb.alembic_version")
assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')") assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')")
assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{PREVIOUS}')" in output assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{PREVIOUS}')" in output
# версия head пишется UPDATE'ами по цепочке 0001 → 0002 → 0003 → 0004 → 0005 — офлайн-SQL доходит до head # версия head пишется UPDATE'ами по цепочке 0001 → 0002 → 0003 → 0004 → 0005 → 0006 — офлайн-SQL доходит до head
for new_version, old_version in ((V5_14, PREVIOUS), (V5_15, V5_14), (V5_16, V5_15), (HEAD, V5_16)): for new_version, old_version in ((V5_14, PREVIOUS), (V5_15, V5_14), (V5_16, V5_15), (V5_17, V5_16), (HEAD, V5_17)):
assert ( assert (
f"UPDATE dkb.alembic_version SET version_num='{new_version}' " f"UPDATE dkb.alembic_version SET version_num='{new_version}' "
f"WHERE dkb.alembic_version.version_num = '{old_version}'" in output f"WHERE dkb.alembic_version.version_num = '{old_version}'" in output