feat(model): DBML v5.14 — related_group_member.kind, audit_action +2, миграция 0002, последовательности кодов

- RelatedGroupMember.kind (related_group_kind, NOT NULL) между legal_entity_id и valid_from, как в DBML;
  частичный уникальный индекс uq_related_group_member_entity_kind_current (legal_entity_id, kind) WHERE is_current —
  ЮЛ одновременно в одной ГСК и одной ГСЗ.
- AuditAction: LEGAL_ENTITY_CHANGE (после GROUP_CHANGE), USER_CHANGE (после USER_ROLE_CHANGE).
- Последовательности company_group_code_seq / related_group_code_seq в metadata (design D-5).
- Миграция 0002_dbml_v5_14: related_group_member пересоздаётся с переносом строк (kind из группы), чтобы порядок
  колонок совпал с DBML и create_all; ALTER TYPE ADD VALUE ×2; downgrade пересоздаёт audit_action v5.13.
- DBML_VERSION = 5.14, пути к data-model-v5.14.dbml (Makefile, dbml_parity), тесты: откат на шаг, перенос строк,
  уникальность gsk+gsz, офлайн-SQL до head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-04 20:49:32 +04:00
parent 13d11c074f
commit 458392e94d
25 changed files with 312 additions and 62 deletions

View File

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

View File

@ -0,0 +1,124 @@
"""DBML v5.14: related_group_member.kind + partial unique (legal_entity_id, kind), audit_action += LEGAL_ENTITY_CHANGE,
USER_CHANGE, последовательности кодов ГК/ГСК (design D-1, D-5).
`related_group_member` пересоздаётся (копия данных → DROP → CREATE → INSERT), а не `ADD COLUMN`: DBML ставит `kind`
сразу после `legal_entity_id`, PostgreSQL добавляет колонку только в конец, а каталог после миграции обязан совпадать
с `Base.metadata.create_all` (порядок колонок — тоже). На таблицу никто не ссылается по FK; `kind` берётся из группы.
`ALTER TYPE … ADD VALUE` необратим — downgrade пересоздаёт тип audit_action со значениями v5.13 (строк с новыми
значениями в audit_event быть не должно; иначе downgrade падает на USING — это осознанно).
Revision ID: 0002_dbml_v5_14
Revises: 0001_dbml_v5_13
Create Date: 2026-10-04
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.schema import CreateSequence, DropSequence
from alembic import op
revision: str = "0002_dbml_v5_14"
down_revision: str | Sequence[str] | None = "0001_dbml_v5_13"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
SCHEMA = "dkb"
TABLE = "related_group_member"
OLD_INDEX = "uq_related_group_member_entity_current"
NEW_INDEX = "uq_related_group_member_entity_kind_current"
GROUP_ENTITY_INDEX = "uq_related_group_member_group_entity_current"
#: Временная копия строк на время пересоздания таблицы (в схеме dkb: прав на TEMP у роли миграций может не быть).
COPY_TABLE = "related_group_member_v5_13"
SEQUENCES = ("company_group_code_seq", "related_group_code_seq")
AUDIT_ACTION_V5_13 = (
"LOGIN", "LOGIN_FAILED", "LOGOUT", "CONSOLIDATION_CREATE", "CONSOLIDATION_STATUS_CHANGE", "CONSOLIDATION_APPROVE",
"CONSOLIDATION_VERIFY", "GROUP_CHANGE", "MEMBERSHIP_CHANGE", "ASSIGNMENT_CHANGE", "FILE_UPLOAD", "FILE_DELETE",
"FILE_REPLACE", "DOCUMENT_CONFIRM", "CONSOLIDATION_LOCK", "CONSOLIDATION_LOCK_TAKEOVER", "DISCLOSURE_EDIT",
"RECOGNIZED_DATA_EDIT", "OSV_COUNTERPARTY_MAP", "OSV_TYPE_OVERRIDE", "CORRECTION_CREATE", "CORRECTION_EDIT",
"CORRECTION_REVOKE", "GOODWILL_EDIT", "EXPORT", "DIRECTORY_IMPORT", "USER_ROLE_CHANGE", "ROLE_PERMISSION_CHANGE",
) # fmt: skip
def _create_member_table() -> None:
"""`related_group_member` v5.14: колонки в порядке DBML, имена ограничений и индексов — как в ORM."""
op.create_table(
TABLE,
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("related_group_id", sa.UUID(), nullable=False),
sa.Column("legal_entity_id", sa.UUID(), nullable=False),
sa.Column("kind", postgresql.ENUM(name="related_group_kind", schema=SCHEMA, create_type=False), nullable=False),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=False),
sa.Column("valid_to", sa.DateTime(timezone=True), nullable=True),
sa.Column("is_current", sa.Boolean(), nullable=False),
sa.Column("created_by", sa.UUID(), nullable=True),
sa.ForeignKeyConstraint(
["created_by"], ["dkb.app_user.id"], name=op.f("fk_related_group_member_created_by_app_user")
),
sa.ForeignKeyConstraint(
["legal_entity_id"],
["dkb.legal_entity.id"],
name=op.f("fk_related_group_member_legal_entity_id_legal_entity"),
),
sa.ForeignKeyConstraint(
["related_group_id"],
["dkb.related_group.id"],
name=op.f("fk_related_group_member_related_group_id_related_group"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_related_group_member")),
schema=SCHEMA,
)
op.create_index(
NEW_INDEX,
TABLE,
["legal_entity_id", "kind"],
unique=True,
schema=SCHEMA,
postgresql_where=sa.text("is_current"),
)
op.create_index(
GROUP_ENTITY_INDEX,
TABLE,
["related_group_id", "legal_entity_id"],
unique=True,
schema=SCHEMA,
postgresql_where=sa.text("is_current"),
)
def upgrade() -> None:
# копия текущих строк с kind группы (FK related_group_id NOT NULL — JOIN не теряет строк)
op.execute(
f"CREATE TABLE {SCHEMA}.{COPY_TABLE} AS SELECT m.id, m.related_group_id, m.legal_entity_id, g.kind, "
f"m.valid_from, m.valid_to, m.is_current, m.created_by FROM {SCHEMA}.{TABLE} m "
f"JOIN {SCHEMA}.related_group g ON g.id = m.related_group_id"
)
op.drop_table(TABLE, schema=SCHEMA) # вместе с индексами v5.13
_create_member_table()
op.execute(f"INSERT INTO {SCHEMA}.{TABLE} SELECT * FROM {SCHEMA}.{COPY_TABLE}")
op.drop_table(COPY_TABLE, schema=SCHEMA)
# порядок значений — как в DBML v5.14 (сверка enum'ов сравнивает упорядоченные списки)
op.execute(f"ALTER TYPE {SCHEMA}.audit_action ADD VALUE IF NOT EXISTS 'LEGAL_ENTITY_CHANGE' AFTER 'GROUP_CHANGE'")
op.execute(f"ALTER TYPE {SCHEMA}.audit_action ADD VALUE IF NOT EXISTS 'USER_CHANGE' AFTER 'USER_ROLE_CHANGE'")
for name in SEQUENCES:
op.execute(CreateSequence(sa.Sequence(name, schema=SCHEMA)))
def downgrade() -> None:
for name in reversed(SEQUENCES):
op.execute(DropSequence(sa.Sequence(name, schema=SCHEMA)))
op.drop_index(NEW_INDEX, table_name=TABLE, schema=SCHEMA)
op.create_index(
OLD_INDEX, TABLE, ["legal_entity_id"], unique=True, schema=SCHEMA, postgresql_where=sa.text("is_current")
)
op.drop_column(TABLE, "kind", schema=SCHEMA)
op.execute(f"ALTER TYPE {SCHEMA}.audit_action RENAME TO audit_action_old")
postgresql.ENUM(*AUDIT_ACTION_V5_13, name="audit_action", schema=SCHEMA).create(op.get_bind(), checkfirst=False)
op.execute(
f"ALTER TABLE {SCHEMA}.audit_event ALTER COLUMN action TYPE {SCHEMA}.audit_action "
f"USING action::text::{SCHEMA}.audit_action"
)
op.execute(f"DROP TYPE {SCHEMA}.audit_action_old")

View File

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

View File

@ -56,7 +56,7 @@ from sqlalchemy.sql.elements import TextClause
from sqlalchemy.sql.functions import FunctionElement
SCHEMA = "dkb"
DBML_FILE_NAME = "data-model-v5.13.dbml"
DBML_FILE_NAME = "data-model-v5.14.dbml"
ORM = "ORM"
DBML = "DBML"
ABSENT = "нет"

View File

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

View File

@ -1,4 +1,4 @@
"""Часть 02 DBML v5.13 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`)."""
"""Часть 02 DBML v5.14 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`)."""
import datetime
import uuid

View File

@ -1,4 +1,4 @@
"""Часть 03 DBML v5.13 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные)."""
"""Часть 03 DBML v5.14 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные)."""
import datetime
import uuid

View File

@ -1,4 +1,4 @@
"""Часть 09 DBML v5.13 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов.
"""Часть 09 DBML v5.14 «Комментарии, аудит, ЕХФД, прочее»: комментарии, журнал аудита, срезы ЕХ, чек-лист документов.
`audit_event` — append-only, высокообъёмная (before/after — jsonb). Срезы ЕХ самодостаточны (v5.12): FK на
консолидацию и её результаты отсутствуют (их удаляют по сроку, срез хранится «вечно»): `ex_snapshot.source_version_id`

View File

@ -6,7 +6,7 @@ from sqlalchemy import MetaData
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, mapped_column
#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.13).
#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.14).
SCHEMA = "dkb"
#: Стандартная схема имён SQLAlchemy. Нужна, чтобы ORM, миграция 0001 и `alembic autogenerate` давали одни и те же

View File

@ -1,4 +1,4 @@
"""Часть 07 DBML v5.13 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы.
"""Часть 07 DBML v5.14 «Консолидация»: консолидация ГК за период, версии, маршрут, блокировка, периметр, документы.
Производные консолидации (сводная ОСВ, пары ВГО, шахматка) НЕ хранятся (DECISION-5): считаются запросом из
`osv_entry` × `consolidation_member` × `elimination_rule`. Версия после VERIFIED — read-only (ТЗ 6.10).

View File

@ -1,15 +1,20 @@
"""Часть 01 DBML v5.13 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2)."""
"""Часть 01 DBML v5.14 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2)."""
import datetime
import uuid
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, String, text
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Sequence, String, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base, UUIDPrimaryMixin
from .base import SCHEMA, Base, UUIDPrimaryMixin
from .enums import EntityType, LeHistoryEvent, RelatedGroupKind, pg_enum
#: Последовательности кодов ГК и ГСК/ГСЗ (design D-5): `GK-000001`, `GSK-000001`/`GSZ-000001`. В DBML не описываются
#: (не таблицы); создаются миграцией 0002 и `Base.metadata.create_all` (привязаны к metadata).
COMPANY_GROUP_CODE_SEQ = Sequence("company_group_code_seq", schema=SCHEMA, metadata=Base.metadata)
RELATED_GROUP_CODE_SEQ = Sequence("related_group_code_seq", schema=SCHEMA, metadata=Base.metadata)
class RegionalOffice(UUIDPrimaryMixin, Base):
"""Филиал: ГО и региональные филиалы (DECISION-3: ГО — обычная запись с `is_head_office`)."""
@ -148,10 +153,11 @@ class RelatedGroupMember(UUIDPrimaryMixin, Base):
unique=True,
postgresql_where=text("is_current"),
),
# legal_entity_id: partial UNIQUE WHERE is_current — ЮЛ только в одной ГСК или ГСЗ (ТЗ 6.1, ANL-030)
# (legal_entity_id, kind): partial UNIQUE WHERE is_current — v5.14: ЮЛ в одной ГСК И одной ГСЗ одновременно
Index(
"uq_related_group_member_entity_current",
"uq_related_group_member_entity_kind_current",
"legal_entity_id",
"kind",
unique=True,
postgresql_where=text("is_current"),
),
@ -163,6 +169,7 @@ class RelatedGroupMember(UUIDPrimaryMixin, Base):
legal_entity_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False
)
kind: Mapped[RelatedGroupKind] = mapped_column(pg_enum("related_group_kind"), nullable=False)
valid_from: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False)
valid_to: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
is_current: Mapped[bool] = mapped_column(Boolean, nullable=False)

View File

@ -1,6 +1,6 @@
"""Enum'ы модели данных DBML v5.13 (27 штук) и фабрика `pg_enum` для PostgreSQL-колонок.
"""Enum'ы модели данных DBML v5.14 (27 штук) и фабрика `pg_enum` для PostgreSQL-колонок.
Источник истины — docs/model/data-model-v5.13.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке
Источник истины — docs/model/data-model-v5.14.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке
DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются.
Имя члена совпадает со значением (все значения DBML — валидные python-идентификаторы, кириллица допустима).
Классы — `enum.StrEnum` (подкласс `str`) — удобно для pydantic/JSON; `values_callable` в `pg_enum` в БД пишет `.value`.
@ -269,6 +269,7 @@ class AuditAction(enum.StrEnum):
CONSOLIDATION_APPROVE = "CONSOLIDATION_APPROVE"
CONSOLIDATION_VERIFY = "CONSOLIDATION_VERIFY"
GROUP_CHANGE = "GROUP_CHANGE"
LEGAL_ENTITY_CHANGE = "LEGAL_ENTITY_CHANGE"
MEMBERSHIP_CHANGE = "MEMBERSHIP_CHANGE"
ASSIGNMENT_CHANGE = "ASSIGNMENT_CHANGE"
FILE_UPLOAD = "FILE_UPLOAD"
@ -288,6 +289,7 @@ class AuditAction(enum.StrEnum):
EXPORT = "EXPORT"
DIRECTORY_IMPORT = "DIRECTORY_IMPORT"
USER_ROLE_CHANGE = "USER_ROLE_CHANGE"
USER_CHANGE = "USER_CHANGE"
ROLE_PERMISSION_CHANGE = "ROLE_PERMISSION_CHANGE"

View File

@ -1,4 +1,4 @@
"""Часть 04 DBML v5.13 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания.
"""Часть 04 DBML v5.14 «Файлы / распознавание»: реестр S3, физические файлы, документы, задания распознавания.
Цикл FK `stored_file.current_recognition_job_id` -> `recognition_job` и `recognition_job.file_id` -> `stored_file`
разорван через `use_alter=True` на nullable-стороне (`stored_file.current_recognition_job_id`): `create_all` создаёт

View File

@ -1,4 +1,4 @@
"""Часть 08 DBML v5.13 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента.
"""Часть 08 DBML v5.14 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента.
Корректировки и гудвилл — ручные решения аналитика, хранятся поверх первички (overlay). Результат КФО
замораживается при VERIFIED. Деньги — `numeric(18,2)`, проценты — `numeric(7,4)` / `numeric(9,4)`.

View File

@ -1,4 +1,4 @@
"""Часть 05 DBML v5.13 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5).
"""Часть 05 DBML v5.14 «ОСВ»: строка оборотно-сальдовой ведомости (ТЗ 6.2, 6.5).
Первичка не мутируется: сырые значения хранятся как пришли, правка аналитика — рядом
(`is_modified`, `original_values`, `modified_by/at` + audit_event(RECOGNIZED_DATA_EDIT)).

View File

@ -1,4 +1,4 @@
"""Часть 06 DBML v5.13 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н).
"""Часть 06 DBML v5.14 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н).
Первичка не мутируется: `*_original_value` / `original_amount` / `is_modified` — рядом с распознанным значением.
Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету.

View File

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

View File

@ -167,9 +167,9 @@ def test_nullable_pk_unique_and_order_mismatches_are_detected() -> None:
def test_missing_extra_and_wrong_where_indexes_are_detected() -> None:
metadata = clone_metadata()
member = metadata.tables["dkb.related_group_member"]
index = next(i for i in member.indexes if i.name == "uq_related_group_member_entity_current")
index = next(i for i in member.indexes if i.name == "uq_related_group_member_entity_kind_current")
index.dialect_options["postgresql"]["where"] = text("is_current AND valid_to IS NULL")
label = "related_group_member: индекс (legal_entity_id)"
label = "related_group_member: индекс (legal_entity_id, kind)"
assert find_problems(metadata, {"related_group_member"}) == [
f"{label}: ожидалось unique WHERE is_current / фактически unique WHERE is_current and valid_to is null"
]

View File

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

View File

@ -1,4 +1,5 @@
"""Миграция 0001 (DBML v5.13) на PostgreSQL 16: полный цикл, пустой autogenerate, схема = схеме ORM.
"""Миграции 0001 (DBML v5.13) и 0002 (v5.14) на PostgreSQL 16: полный цикл, откат на шаг, пустой autogenerate,
схема = схеме ORM.
Spec data-model, требование «Миграции применяются с нуля и откатываются» (сценарии «Полный цикл» и «Автогенерация
ничего не находит»), design D-2. Alembic запускается отдельным процессом (`python -m alembic -x dburl=...`), как из
@ -6,6 +7,7 @@ CLI: `env.py` вызывает `asyncio.run(...)`, а внутри event loop py
перенастраивает логирование тестов через `fileConfig`. Каждая проверка идёт на своей временной БД (`scratch_db_url`).
"""
import datetime
import uuid
from typing import Any
@ -28,7 +30,8 @@ from tests.test_models_part_07_09 import (
_check_active_lock_is_unique,
)
HEAD = "0001_dbml_v5_13"
HEAD = "0002_dbml_v5_14"
PREVIOUS = "0001_dbml_v5_13"
VERSION_TABLE = "alembic_version"
FK_CYCLE = "fk_stored_file_current_recognition_job_id_recognition_job"
@ -150,6 +153,109 @@ async def test_full_cycle_upgrade_downgrade_upgrade(scratch_db_url: str) -> None
await engine.dispose()
@pytest.mark.integration
async def test_downgrade_one_step(scratch_db_url: str) -> None:
"""Сценарий «Откат на один шаг»: head → 0001 возвращает схему v5.13 (без kind, старый индекс и audit_action)."""
engine = create_async_engine(scratch_db_url)
try:
await alembic(scratch_db_url, "upgrade", "head")
await alembic(scratch_db_url, "downgrade", PREVIOUS)
async with engine.connect() as conn:
assert await _scalars(conn, "SELECT version_num FROM dkb.alembic_version") == [PREVIOUS]
kind_columns = await _scalars(
conn,
"SELECT column_name FROM information_schema.columns WHERE table_schema = 'dkb' "
"AND table_name = 'related_group_member' AND column_name = 'kind'",
)
assert kind_columns == []
indexes = set(
await _scalars(
conn,
"SELECT indexname FROM pg_indexes WHERE schemaname = 'dkb' AND tablename = 'related_group_member'",
)
)
assert "uq_related_group_member_entity_current" in indexes
assert "uq_related_group_member_entity_kind_current" not in indexes
[labels] = await _scalars(conn, "SELECT enum_range(NULL::dkb.audit_action)::text[]")
assert "LEGAL_ENTITY_CHANGE" not in labels
assert "USER_CHANGE" not in labels
sequences = await _scalars(
conn, "SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'dkb'"
)
assert sequences == []
await alembic(scratch_db_url, "upgrade", "head")
async with engine.connect() as conn:
await _assert_full_model(conn)
[labels] = await _scalars(conn, "SELECT enum_range(NULL::dkb.audit_action)::text[]")
assert labels == [m.value for m in ENUMS["audit_action"]]
sequences = set(
await _scalars(
conn, "SELECT sequence_name FROM information_schema.sequences WHERE sequence_schema = 'dkb'"
)
)
assert sequences == {"company_group_code_seq", "related_group_code_seq"}
finally:
await engine.dispose()
@pytest.mark.integration
async def test_upgrade_0002_keeps_membership_and_fills_kind(scratch_db_url: str) -> None:
"""0002 пересоздаёт `related_group_member`: строки v5.13 сохраняются, `kind` = `related_group.kind` группы."""
engine = create_async_engine(scratch_db_url)
now = datetime.datetime.now(datetime.UTC)
ids = {k: uuid.uuid4() for k in ("ro", "cg", "le", "gsk", "gsz", "m1", "m2")}
try:
await alembic(scratch_db_url, "upgrade", PREVIOUS)
async with engine.begin() as conn:
await conn.execute(text("INSERT INTO dkb.regional_office (id, name) VALUES (:ro, 'ГО')"), ids)
await conn.execute(
text("INSERT INTO dkb.company_group (id, code, name, valid_from) VALUES (:cg, 'G1', 'ГК', :now)"),
{**ids, "now": now},
)
await conn.execute(
text("INSERT INTO dkb.legal_entity (id, inn, name, valid_from) VALUES (:le, '1', 'ЮЛ', :now)"),
{**ids, "now": now},
)
for kind in ("gsk", "gsz"):
await conn.execute(
text(
"INSERT INTO dkb.related_group (id, code, company_group_id, kind, name) "
"VALUES (:g, :code, :cg, :kind, 'Группа')"
),
{"g": ids[kind], "code": kind, "cg": ids["cg"], "kind": kind},
)
# v5.13: одно текущее членство (в ГСЗ) и закрытое (в ГСК)
member = text(
"INSERT INTO dkb.related_group_member (id, related_group_id, legal_entity_id, valid_from, valid_to, "
"is_current) VALUES (:id, :g, :le, :now, :to, :cur)"
)
await conn.execute(
member, {"id": ids["m1"], "g": ids["gsz"], "le": ids["le"], "now": now, "to": None, "cur": True}
)
await conn.execute(
member, {"id": ids["m2"], "g": ids["gsk"], "le": ids["le"], "now": now, "to": now, "cur": False}
)
await alembic(scratch_db_url, "upgrade", "head")
async with engine.connect() as conn:
rows = (
await conn.execute(
text("SELECT id, related_group_id, kind::text, valid_to, is_current FROM dkb.related_group_member")
)
).all()
assert {tuple(r) for r in rows} == {
(ids["m1"], ids["gsz"], "gsz", None, True),
(ids["m2"], ids["gsk"], "gsk", now, False),
}
leftovers = await _scalars(
conn, "SELECT relname FROM pg_class WHERE relnamespace = 'dkb'::regnamespace AND relname LIKE '%v5_13'"
)
assert leftovers == [] # временная копия удалена
finally:
await engine.dispose()
@pytest.mark.integration
async def test_autogenerate_finds_nothing_after_upgrade(scratch_db_url: str) -> None:
"""Сценарий «Автогенерация ничего не находит»: compare_metadata пуст, `alembic check` не видит операций."""
@ -228,13 +334,13 @@ async def test_migrated_schema_behaviour(scratch_db_url: str) -> None:
text(
"SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'dkb' AND indexname IN "
"('uq_document_file_type_account', 'uq_consolidation_lock_version_active', "
"'uq_related_group_member_entity_current', 'uq_related_group_member_group_entity_current')"
"'uq_related_group_member_entity_kind_current', 'uq_related_group_member_group_entity_current')"
)
)
indexdefs = {row[0]: row[1] for row in rows}
assert "NULLS NOT DISTINCT" in indexdefs["uq_document_file_type_account"]
assert "WHERE (released_at IS NULL)" in indexdefs["uq_consolidation_lock_version_active"]
for name in ("uq_related_group_member_entity_current", "uq_related_group_member_group_entity_current"):
for name in ("uq_related_group_member_entity_kind_current", "uq_related_group_member_group_entity_current"):
assert indexdefs[name].startswith("CREATE UNIQUE INDEX")
assert "WHERE is_current" in indexdefs[name]
@ -307,7 +413,12 @@ def test_offline_sql_creates_schema_before_version_table() -> None:
assert "CREATE SCHEMA IF NOT EXISTS" not in output # требует права CREATE на базу даже при существующей схеме
assert schema_at < output.index("CREATE TABLE dkb.alembic_version")
assert schema_at < output.index("CREATE TYPE dkb.entity_type AS ENUM ('ЮЛ', 'ИП')")
assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{HEAD}')" in output
assert f"INSERT INTO dkb.alembic_version (version_num) VALUES ('{PREVIOUS}')" in output
# с двумя ревизиями версия head пишется UPDATE'ом после 0001 — офлайн-SQL доходит до head
assert (
f"UPDATE dkb.alembic_version SET version_num='{HEAD}' WHERE dkb.alembic_version.version_num = '{PREVIOUS}'"
in output
)
def test_cluster_commands_use_service_database() -> None:

View File

@ -1,4 +1,4 @@
"""ORM частей 01–03 DBML v5.13 (справочники, доступ, учёт): чётность с DBML без БД и create_all на PostgreSQL."""
"""ORM частей 01–03 DBML v5.14 (справочники, доступ, учёт): чётность с DBML без БД и create_all на PostgreSQL."""
import datetime
import uuid
@ -109,7 +109,7 @@ async def test_create_all_on_scratch_db(scratch_db_url: str) -> None:
)
)
}
for name in ("uq_related_group_member_group_entity_current", "uq_related_group_member_entity_current"):
for name in ("uq_related_group_member_group_entity_current", "uq_related_group_member_entity_kind_current"):
assert "UNIQUE" in indexdefs[name]
assert "WHERE is_current" in indexdefs[name]
pk = await conn.run_sync(lambda c: inspect(c).get_pk_constraint("role_permission", schema="dkb"))
@ -122,9 +122,9 @@ async def test_create_all_on_scratch_db(scratch_db_url: str) -> None:
async def _check_related_group_member_uniqueness(engine: AsyncEngine) -> None:
"""ЮЛ — только в одной ГСК/ГСЗ среди текущих; закрытая версия не мешает (ТЗ 6.1, ANL-030)."""
"""ЮЛ — в одной ГСК и одной ГСЗ одновременно (v5.14, решение владельца 04.10.2026); закрытая версия не мешает."""
now = datetime.datetime.now(datetime.UTC)
ids = {k: uuid.uuid4() for k in ("ro", "cg", "le", "g1", "g2", "m1", "m2", "m3")}
ids = {k: uuid.uuid4() for k in ("ro", "cg", "le", "g1", "g2", "g3", "m1", "m2", "m3", "m4")}
async with engine.begin() as conn:
await conn.execute(text("INSERT INTO dkb.regional_office (id, name) VALUES (:ro, 'ГО')"), ids)
await conn.execute(
@ -135,22 +135,28 @@ async def _check_related_group_member_uniqueness(engine: AsyncEngine) -> None:
text("INSERT INTO dkb.legal_entity (id, inn, name, valid_from) VALUES (:le, '1', 'ЮЛ', :now)"),
{**ids, "now": now},
)
for g, code in (("g1", "GSK-1"), ("g2", "GSK-2")):
for g, code, kind in (("g1", "GSK-1", "gsk"), ("g2", "GSK-2", "gsk"), ("g3", "GSZ-1", "gsz")):
await conn.execute(
text(
"INSERT INTO dkb.related_group (id, code, company_group_id, kind, name) "
"VALUES (:g, :code, :cg, 'gsk', 'ГСК')"
"VALUES (:g, :code, :cg, :kind, 'ГСК/ГСЗ')"
),
{"g": ids[g], "code": code, "cg": ids["cg"]},
{"g": ids[g], "code": code, "cg": ids["cg"], "kind": kind},
)
insert = text(
"INSERT INTO dkb.related_group_member (id, related_group_id, legal_entity_id, valid_from, is_current) "
"VALUES (:id, :g, :le, :now, :cur)"
"INSERT INTO dkb.related_group_member (id, related_group_id, legal_entity_id, kind, valid_from, is_current) "
"VALUES (:id, :g, :le, :kind, :now, :cur)"
)
le, gsk, gsz = ids["le"], "gsk", "gsz"
async with engine.begin() as conn:
await conn.execute(insert, {"id": ids["m1"], "g": ids["g1"], "le": ids["le"], "now": now, "cur": True})
# закрытая версия в другой группе — допустима
await conn.execute(insert, {"id": ids["m2"], "g": ids["g2"], "le": ids["le"], "now": now, "cur": False})
# текущее членство в ГСК g1
await conn.execute(insert, {"id": ids["m1"], "g": ids["g1"], "le": le, "kind": gsk, "now": now, "cur": True})
# текущее членство того же ЮЛ в ГСЗ g3 — допустимо с v5.14
await conn.execute(insert, {"id": ids["m2"], "g": ids["g3"], "le": le, "kind": gsz, "now": now, "cur": True})
# закрытая версия в другой ГСК — допустима
await conn.execute(insert, {"id": ids["m3"], "g": ids["g2"], "le": le, "kind": gsk, "now": now, "cur": False})
with pytest.raises(IntegrityError):
async with engine.begin() as conn: # второе текущее членство того же ЮЛ — нарушение
await conn.execute(insert, {"id": ids["m3"], "g": ids["g2"], "le": ids["le"], "now": now, "cur": True})
async with engine.begin() as conn: # второе текущее членство того же ЮЛ в ГСК — нарушение
await conn.execute(
insert, {"id": ids["m4"], "g": ids["g2"], "le": le, "kind": gsk, "now": now, "cur": True}
)

View File

@ -1,4 +1,4 @@
"""ORM частей 04–06 DBML v5.13 (файлы, ОСВ, отчётность): чётность с DBML без БД и create_all на PostgreSQL."""
"""ORM частей 04–06 DBML v5.14 (файлы, ОСВ, отчётность): чётность с DBML без БД и create_all на PostgreSQL."""
import datetime
import decimal

View File

@ -1,4 +1,4 @@
"""ORM частей 07–09 DBML v5.13 (консолидация, КФО, аудит/ЕХ/прочее) и ИТОГОВАЯ полнота всех 43 таблиц."""
"""ORM частей 07–09 DBML v5.14 (консолидация, КФО, аудит/ЕХ/прочее) и ИТОГОВАЯ полнота всех 43 таблиц."""
import datetime
@ -112,7 +112,7 @@ def test_ex_snapshot_has_no_fk_to_consolidation() -> None:
def test_all_43_tables_registered_and_match_dbml_names() -> None:
"""Состав metadata == таблицы DBML v5.13 (имена буквально), всего 43, все в схеме dkb."""
"""Состав metadata == таблицы DBML v5.14 (имена буквально), всего 43, все в схеме dkb."""
tables = list(Base.metadata.sorted_tables)
assert len(tables) == EXPECTED_TABLE_COUNT
assert all(t.schema == "dkb" for t in tables)

View File

@ -50,16 +50,16 @@
**Interfaces:**
- Produces: `RelatedGroupMember.kind: Mapped[RelatedGroupKind]` (колонка между `legal_entity_id` и `valid_from`, как в DBML); `COMPANY_GROUP_CODE_SEQ = Sequence("company_group_code_seq", schema=SCHEMA, metadata=Base.metadata)`, `RELATED_GROUP_CODE_SEQ = Sequence("related_group_code_seq", …)` в `src/models/directories.py`; `AuditAction.LEGAL_ENTITY_CHANGE`, `AuditAction.USER_CHANGE`.
- [ ] **Step 1: Обновить тест уникальности членства (падает на старой схеме)**
- [x] **Step 1: Обновить тест уникальности членства (падает на старой схеме)**
В `_check_related_group_member_uniqueness` (`tests/test_models_part_01_03.py`): INSERT в `related_group_member` получает колонку `kind`; сценарий: `g1` (gsk) текущее — ок; `g3` (gsz, создать третью группу) текущее для того же ЮЛ — **ок** (v5.14); `g2` (gsk) текущее — `IntegrityError`; закрытая версия в `g2` — ок. Докстринг: «ЮЛ — в одной ГСК и одной ГСЗ одновременно (v5.14, решение владельца 04.10.2026)».
- [ ] **Step 2: Запустить — убедиться, что падает**
- [x] **Step 2: Запустить — убедиться, что падает**
Run: `cd api && .venv/bin/pytest -m integration tests/test_models_part_01_03.py -k related_group_member -v`
Expected: FAIL (`column "kind" does not exist` или IntegrityError на gsz).
- [ ] **Step 3: ORM**
- [x] **Step 3: ORM**
`src/models/directories.py`:
```python
@ -88,7 +88,7 @@ RELATED_GROUP_CODE_SEQ = Sequence("related_group_code_seq", schema=SCHEMA, metad
```
`src/models/enums.py`: `LEGAL_ENTITY_CHANGE = "LEGAL_ENTITY_CHANGE"` сразу после `GROUP_CHANGE`, `USER_CHANGE = "USER_CHANGE"` сразу после `USER_ROLE_CHANGE`. `src/models/__init__.py`: `DBML_VERSION = "5.14"`. Докстринги модулей `models/*.py`, `scripts/check_dbml_vs_orm.py`: «v5.13» → «v5.14» там, где речь о текущей версии.
- [ ] **Step 4: Миграция 0002**
- [x] **Step 4: Миграция 0002**
`api/alembic/versions/0002_dbml_v5_14.py`:
```python
@ -164,17 +164,17 @@ def downgrade() -> None:
```
`alembic/env.py` менять не нужно. В `tests/test_migrations.py`: `HEAD = "0002_dbml_v5_14"`; добавить тест `test_downgrade_one_step` (сценарий спеки «Откат на один шаг»): `upgrade head` → `downgrade 0001_dbml_v5_13` → в `information_schema.columns` нет `related_group_member.kind`, в `pg_indexes` есть `uq_related_group_member_entity_current`, `enum_range(NULL::dkb.audit_action)` без `LEGAL_ENTITY_CHANGE` → `upgrade head` снова. Если `test_migrated_schema_equals_orm_create_all` сравнивает последовательности — они теперь есть и в metadata, и в миграции.
- [ ] **Step 5: Пути и версии**
- [x] **Step 5: Пути и версии**
`Makefile:6` → `data-model-v5.14.dbml`; `scripts/dbml_parity.py:59` → `"data-model-v5.14.dbml"`; `tests/test_enums.py` → `"5.14"`; `tests/test_check_dbml_script.py` — фиктивный файл `v5.15`, ожидаемое сообщение с `5.15`/`5.14`.
- [ ] **Step 6: Проверить**
- [x] **Step 6: Проверить**
Run: `cd api && .venv/bin/ruff check . && .venv/bin/ruff format --check . && .venv/bin/mypy src scripts && .venv/bin/pytest -m "not integration"` → PASS.
Run: `make dbml-check` (из корня) → пустой вывод, код 0.
Run: `cd api && .venv/bin/pytest -m integration tests/test_migrations.py tests/test_models_part_01_03.py -v` → PASS (цикл 0001→0002→base→head, пустой autogenerate, снимок схемы = create_all, откат на шаг, уникальность gsk+gsz).
- [ ] **Step 7: Commit**
- [x] **Step 7: Commit**
`git add -A && git commit -m "feat(model): DBML v5.14 — related_group_member.kind, audit_action +2, миграция 0002, последовательности кодов"` (+ трейлеры).

View File

@ -5,7 +5,7 @@ test-integration`). Пути — от корня репозитория. Доп
## 1. Модель v5.14 и инфраструктура запросов
- [ ] 1.1 **ORM и миграция 0002.** `api/src/models/directories.py`: `RelatedGroupMember.kind` (enum `related_group_kind`, NOT NULL),
- [x] 1.1 **ORM и миграция 0002.** `api/src/models/directories.py`: `RelatedGroupMember.kind` (enum `related_group_kind`, NOT NULL),
индекс `uq_related_group_member_entity_kind_current (legal_entity_id, kind) WHERE is_current` вместо `…_entity_current`;
`enums.py`: `AuditAction.LEGAL_ENTITY_CHANGE` (после `GROUP_CHANGE`), `USER_CHANGE` (после `USER_ROLE_CHANGE`);
`alembic/versions/0002_dbml_v5_14.py` (явный DDL: колонка с заполнением из `related_group`, индексы, `ALTER TYPE … ADD VALUE`,