diff --git a/Makefile b/Makefile index 6acc5e2..01d2ea0 100644 --- a/Makefile +++ b/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.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 diff --git a/api/alembic/versions/0002_dbml_v5_14.py b/api/alembic/versions/0002_dbml_v5_14.py new file mode 100644 index 0000000..1abd967 --- /dev/null +++ b/api/alembic/versions/0002_dbml_v5_14.py @@ -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") diff --git a/api/scripts/check_dbml_vs_orm.py b/api/scripts/check_dbml_vs_orm.py index c8d3837..2f7cbb6 100644 --- a/api/scripts/check_dbml_vs_orm.py +++ b/api/scripts/check_dbml_vs_orm.py @@ -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`. diff --git a/api/scripts/dbml_parity.py b/api/scripts/dbml_parity.py index ac3feb5..eeb2926 100644 --- a/api/scripts/dbml_parity.py +++ b/api/scripts/dbml_parity.py @@ -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 = "нет" diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index fcbbb72..520bdda 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -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", diff --git a/api/src/models/access.py b/api/src/models/access.py index cf8f275..9c6f0d4 100644 --- a/api/src/models/access.py +++ b/api/src/models/access.py @@ -1,4 +1,4 @@ -"""Часть 02 DBML v5.13 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`).""" +"""Часть 02 DBML v5.14 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`).""" import datetime import uuid diff --git a/api/src/models/accounting.py b/api/src/models/accounting.py index def7fd6..10a69b3 100644 --- a/api/src/models/accounting.py +++ b/api/src/models/accounting.py @@ -1,4 +1,4 @@ -"""Часть 03 DBML v5.13 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные).""" +"""Часть 03 DBML v5.14 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные).""" import datetime import uuid diff --git a/api/src/models/audit_ex.py b/api/src/models/audit_ex.py index 27c1915..4b8ae61 100644 --- a/api/src/models/audit_ex.py +++ b/api/src/models/audit_ex.py @@ -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` diff --git a/api/src/models/base.py b/api/src/models/base.py index 875f3d9..6436aed 100644 --- a/api/src/models/base.py +++ b/api/src/models/base.py @@ -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` давали одни и те же diff --git a/api/src/models/consolidation.py b/api/src/models/consolidation.py index 53135a6..1af26eb 100644 --- a/api/src/models/consolidation.py +++ b/api/src/models/consolidation.py @@ -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). diff --git a/api/src/models/directories.py b/api/src/models/directories.py index 7b0f0a8..849ac92 100644 --- a/api/src/models/directories.py +++ b/api/src/models/directories.py @@ -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) diff --git a/api/src/models/enums.py b/api/src/models/enums.py index 2b621c8..0c69eec 100644 --- a/api/src/models/enums.py +++ b/api/src/models/enums.py @@ -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" diff --git a/api/src/models/files.py b/api/src/models/files.py index 84ff62f..6b2c51a 100644 --- a/api/src/models/files.py +++ b/api/src/models/files.py @@ -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` создаёт diff --git a/api/src/models/kfo.py b/api/src/models/kfo.py index dc0df0f..3ad377b 100644 --- a/api/src/models/kfo.py +++ b/api/src/models/kfo.py @@ -1,4 +1,4 @@ -"""Часть 08 DBML v5.13 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента. +"""Часть 08 DBML v5.14 «КФО»: корректировки, гудвилл, результат КФО (шапка, строки, разрез по компаниям), КФО клиента. Корректировки и гудвилл — ручные решения аналитика, хранятся поверх первички (overlay). Результат КФО замораживается при VERIFIED. Деньги — `numeric(18,2)`, проценты — `numeric(7,4)` / `numeric(9,4)`. diff --git a/api/src/models/osv.py b/api/src/models/osv.py index 3986b4f..a2011bf 100644 --- a/api/src/models/osv.py +++ b/api/src/models/osv.py @@ -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)). diff --git a/api/src/models/statements.py b/api/src/models/statements.py index 877396c..3f6dff5 100644 --- a/api/src/models/statements.py +++ b/api/src/models/statements.py @@ -1,4 +1,4 @@ -"""Часть 06 DBML v5.13 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). +"""Часть 06 DBML v5.14 «Бухгалтерская отчётность»: шапка, колонки-периоды, строки и значения форм ББ/ОФР (157н). Первичка не мутируется: `*_original_value` / `original_amount` / `is_modified` — рядом с распознанным значением. Логические проверки (ТЗ 6.3) и «требует проверки» не хранятся — считаются на лету. diff --git a/api/tests/test_check_dbml_script.py b/api/tests/test_check_dbml_script.py index 3b45f17..1a38b8b 100644 --- a/api/tests/test_check_dbml_script.py +++ b/api/tests/test_check_dbml_script.py @@ -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: diff --git a/api/tests/test_dbml_parity.py b/api/tests/test_dbml_parity.py index 49aa0d0..883ba25 100644 --- a/api/tests/test_dbml_parity.py +++ b/api/tests/test_dbml_parity.py @@ -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" ] diff --git a/api/tests/test_enums.py b/api/tests/test_enums.py index 21811fa..694e815 100644 --- a/api/tests/test_enums.py +++ b/api/tests/test_enums.py @@ -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 diff --git a/api/tests/test_migrations.py b/api/tests/test_migrations.py index 18b1692..dc3f0ee 100644 --- a/api/tests/test_migrations.py +++ b/api/tests/test_migrations.py @@ -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: diff --git a/api/tests/test_models_part_01_03.py b/api/tests/test_models_part_01_03.py index 040e897..8100ec0 100644 --- a/api/tests/test_models_part_01_03.py +++ b/api/tests/test_models_part_01_03.py @@ -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} + ) diff --git a/api/tests/test_models_part_04_06.py b/api/tests/test_models_part_04_06.py index 9126293..4bd8e9a 100644 --- a/api/tests/test_models_part_04_06.py +++ b/api/tests/test_models_part_04_06.py @@ -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 diff --git a/api/tests/test_models_part_07_09.py b/api/tests/test_models_part_07_09.py index c6f75de..49340e3 100644 --- a/api/tests/test_models_part_07_09.py +++ b/api/tests/test_models_part_07_09.py @@ -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) diff --git a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md index ee69440..1cba46c 100644 --- a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md +++ b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase1.md @@ -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, последовательности кодов"` (+ трейлеры). diff --git a/openspec/changes/slice-1-directories/tasks.md b/openspec/changes/slice-1-directories/tasks.md index 6fe1ec9..93388d6 100644 --- a/openspec/changes/slice-1-directories/tasks.md +++ b/openspec/changes/slice-1-directories/tasks.md @@ -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`,