From 8e6223a2735205efd6ca743ee6a9e8642f115b49 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 18:47:23 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20models/enums.py=20=E2=80=94=2027=20enum?= =?UTF-8?q?'=D0=BE=D0=B2=20DBML=20v5.13,=20=D1=84=D0=B0=D0=B1=D1=80=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20pg=5Fenum,=20DBML=5FVERSION?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тест сверяет имена и упорядоченные значения с блоками Enum {} DBML через pydbml. Co-Authored-By: Claude Fable 5.1 --- api/pytest.ini | 3 + api/src/models/__init__.py | 5 +- api/src/models/enums.py | 338 +++++++++++++++++++++++++++++++++++++ api/tests/dbml_helpers.py | 24 +++ api/tests/test_enums.py | 57 +++++++ 5 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 api/src/models/enums.py create mode 100644 api/tests/dbml_helpers.py create mode 100644 api/tests/test_enums.py diff --git a/api/pytest.ini b/api/pytest.ini index 7ae5279..e6f0417 100644 --- a/api/pytest.ini +++ b/api/pytest.ini @@ -19,6 +19,9 @@ env = APP_ENV=test ENV_FILE=.env.test norecursedirs = .git __pycache__ .venv env build dist +# pydbml (dev-зависимость) вызывает устаревшие API pyparsing — шум библиотеки, не нашего кода +filterwarnings = + ignore::DeprecationWarning:pydbml.* markers = integration: тесты, которым нужны PostgreSQL/NATS (docker compose); запускаются отдельно от юнит-тестов fixtures: тесты на реальных данных из ../fixtures (внутренний доступ) diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index 86139b4..faac666 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -2,4 +2,7 @@ from .base import Base, UUIDPrimaryMixin -__all__ = ["Base", "UUIDPrimaryMixin"] +#: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm). +DBML_VERSION = "5.13" + +__all__ = ["DBML_VERSION", "Base", "UUIDPrimaryMixin"] diff --git a/api/src/models/enums.py b/api/src/models/enums.py new file mode 100644 index 0000000..67cc317 --- /dev/null +++ b/api/src/models/enums.py @@ -0,0 +1,338 @@ +"""Enum'ы модели данных DBML v5.13 (27 штук) и фабрика `pg_enum` для PostgreSQL-колонок. + +Источник истины — docs/model/data-model-v5.13.dbml (блоки `Enum {}`). Значения — строки DBML буквально, в порядке +DBML (включая кириллические `"ЮЛ"`/`"ИП"` и значения в разном регистре); `notes` DBML значениями не являются. +Имя члена совпадает со значением (все значения DBML — валидные python-идентификаторы, кириллица допустима). +Классы — `enum.StrEnum` (подкласс `str`) — удобно для pydantic/JSON; `values_callable` в `pg_enum` в БД пишет `.value`. +""" + +import enum + +from sqlalchemy import Enum as SAEnum + +SCHEMA = "dkb" + + +class EntityType(enum.StrEnum): + """DBML `entity_type`.""" + + ЮЛ = "ЮЛ" # noqa: PLC2401 — значение DBML буквально + ИП = "ИП" # noqa: PLC2401 — значение DBML буквально + + +class RelatedGroupKind(enum.StrEnum): + """DBML `related_group_kind`.""" + + gsk = "gsk" + gsz = "gsz" + + +class AccountKind(enum.StrEnum): + """DBML `account_kind`.""" + + A = "A" + P = "P" + AP = "AP" + + +class AccountRowType(enum.StrEnum): + """DBML `account_row_type`.""" + + account = "account" + subaccount = "subaccount" + analytic = "analytic" + + +class BusinessSegment(enum.StrEnum): + """DBML `business_segment`.""" + + DKB = "DKB" + DMSB = "DMSB" + DM = "DM" + + +class DocumentType(enum.StrEnum): + """DBML `document_type`.""" + + BALANCE = "BALANCE" + INCOME = "INCOME" + OSV = "OSV" + DISCLOSURE = "DISCLOSURE" + OTHER = "OTHER" + + +class FileType(enum.StrEnum): + """DBML `file_type`.""" + + XLS = "XLS" + XLSX = "XLSX" + XLSB = "XLSB" + PDF = "PDF" + PNG = "PNG" + JPG = "JPG" + + +class RecognitionPipeline(enum.StrEnum): + """DBML `recognition_pipeline`.""" + + tabular = "tabular" + image = "image" + + +class PeriodQuarter(enum.StrEnum): + """DBML `period_quarter`.""" + + Q1 = "Q1" + Q2 = "Q2" + Q3 = "Q3" + Q4 = "Q4" + + +class FileStatus(enum.StrEnum): + """DBML `file_status`.""" + + UPLOADED = "UPLOADED" + PROCESSING = "PROCESSING" + PROCESSED = "PROCESSED" + ERROR = "ERROR" + SUPERSEDED = "SUPERSEDED" + + +class DocumentStatus(enum.StrEnum): + """DBML `document_status`.""" + + RECOGNIZED = "RECOGNIZED" + NEEDS_CHECK = "NEEDS_CHECK" + CONFIRMED = "CONFIRMED" + ERROR = "ERROR" + + +class S3BucketRole(enum.StrEnum): + """DBML `s3_bucket_role`.""" + + uploads = "uploads" + results = "results" + exports = "exports" + archive = "archive" + + +class RecognitionStatus(enum.StrEnum): + """DBML `recognition_status`.""" + + queued = "queued" + published = "published" + processing = "processing" + done = "done" + needs_check = "needs_check" + error = "error" + timeout = "timeout" + dlq = "dlq" + + +class StatementType(enum.StrEnum): + """DBML `statement_type`.""" + + balance = "balance" + income = "income" + + +class OsvGroupType(enum.StrEnum): + """DBML `osv_group_type`.""" + + ACCOUNT = "ACCOUNT" + DEPARTMENT = "DEPARTMENT" + COUNTERPARTY = "COUNTERPARTY" + CONTRACT = "CONTRACT" + OTHER = "OTHER" + + +class OsvDataType(enum.StrEnum): + """DBML `osv_data_type`.""" + + BU = "BU" + CURRENCY = "CURRENCY" + QUANTITY = "QUANTITY" + SUM = "SUM" + + +class ConsolidationScenario(enum.StrEnum): + """DBML `consolidation_scenario`.""" + + CREATE = "CREATE" + CONFIRM = "CONFIRM" + + +class ConsolidationApproach(enum.StrEnum): + """DBML `consolidation_approach`.""" + + BASIC = "BASIC" + EXTENDED = "EXTENDED" + + +class ConsolidationStatus(enum.StrEnum): + """DBML `consolidation_status`.""" + + INITIATED = "INITIATED" + DOCUMENTS_PROCESSING = "DOCUMENTS_PROCESSING" + DATA_CORRECTION = "DATA_CORRECTION" + CONSOLIDATION_RF = "CONSOLIDATION_RF" + ADJUSTMENT = "ADJUSTMENT" + VERIFICATION_RF = "VERIFICATION_RF" + CONSOLIDATION_GO = "CONSOLIDATION_GO" + VERIFICATION_GO = "VERIFICATION_GO" + VERIFIED = "VERIFIED" + CANCELLED = "CANCELLED" + REJECTED = "REJECTED" + ARCHIVED = "ARCHIVED" + + +class CorrectionStatus(enum.StrEnum): + """DBML `correction_status`.""" + + DRAFT = "DRAFT" + APPLIED = "APPLIED" + REVOKED = "REVOKED" + + +class DocumentCheckResult(enum.StrEnum): + """DBML `document_check_result`.""" + + MATCH = "MATCH" + MISMATCH = "MISMATCH" + UNKNOWN = "UNKNOWN" + + +class LeHistoryEvent(enum.StrEnum): + """DBML `le_history_event`.""" + + CREATE = "CREATE" + UPDATE = "UPDATE" + MOVE_TO_GROUP = "MOVE_TO_GROUP" + CLOSE = "CLOSE" + REMOVE_FROM_GROUP = "REMOVE_FROM_GROUP" + + +class ExSnapshotStatus(enum.StrEnum): + """DBML `ex_snapshot_status`.""" + + ACTUAL = "ACTUAL" + ARCHIVE = "ARCHIVE" + + +class PermissionObject(enum.StrEnum): + """DBML `permission_object`.""" + + DIRECTORY = "DIRECTORY" + DOCUMENT = "DOCUMENT" + CONSOLIDATION = "CONSOLIDATION" + KFO = "KFO" + EX = "EX" + EXPORT = "EXPORT" + USERS = "USERS" + AUDIT = "AUDIT" + + +class PermissionAction(enum.StrEnum): + """DBML `permission_action`.""" + + VIEW = "VIEW" + EDIT = "EDIT" + APPROVE = "APPROVE" + VERIFY = "VERIFY" + DELETE = "DELETE" + EXPORT = "EXPORT" + ADMIN = "ADMIN" + + +class AuditSection(enum.StrEnum): + """DBML `audit_section`.""" + + DIRECTORIES = "DIRECTORIES" + CONSOLIDATION = "CONSOLIDATION" + PRIMARY_DOCUMENTS = "PRIMARY_DOCUMENTS" + RECOGNITION = "RECOGNITION" + KFO = "KFO" + EXPORT = "EXPORT" + ACCESS = "ACCESS" + SESSION = "SESSION" + USERS = "USERS" + + +class AuditAction(enum.StrEnum): + """DBML `audit_action`.""" + + LOGIN = "LOGIN" + LOGIN_FAILED = "LOGIN_FAILED" + LOGOUT = "LOGOUT" + CONSOLIDATION_CREATE = "CONSOLIDATION_CREATE" + CONSOLIDATION_STATUS_CHANGE = "CONSOLIDATION_STATUS_CHANGE" + CONSOLIDATION_APPROVE = "CONSOLIDATION_APPROVE" + CONSOLIDATION_VERIFY = "CONSOLIDATION_VERIFY" + GROUP_CHANGE = "GROUP_CHANGE" + MEMBERSHIP_CHANGE = "MEMBERSHIP_CHANGE" + ASSIGNMENT_CHANGE = "ASSIGNMENT_CHANGE" + FILE_UPLOAD = "FILE_UPLOAD" + FILE_DELETE = "FILE_DELETE" + FILE_REPLACE = "FILE_REPLACE" + DOCUMENT_CONFIRM = "DOCUMENT_CONFIRM" + CONSOLIDATION_LOCK = "CONSOLIDATION_LOCK" + CONSOLIDATION_LOCK_TAKEOVER = "CONSOLIDATION_LOCK_TAKEOVER" + DISCLOSURE_EDIT = "DISCLOSURE_EDIT" + RECOGNIZED_DATA_EDIT = "RECOGNIZED_DATA_EDIT" + OSV_COUNTERPARTY_MAP = "OSV_COUNTERPARTY_MAP" + OSV_TYPE_OVERRIDE = "OSV_TYPE_OVERRIDE" + CORRECTION_CREATE = "CORRECTION_CREATE" + CORRECTION_EDIT = "CORRECTION_EDIT" + CORRECTION_REVOKE = "CORRECTION_REVOKE" + GOODWILL_EDIT = "GOODWILL_EDIT" + EXPORT = "EXPORT" + DIRECTORY_IMPORT = "DIRECTORY_IMPORT" + USER_ROLE_CHANGE = "USER_ROLE_CHANGE" + ROLE_PERMISSION_CHANGE = "ROLE_PERMISSION_CHANGE" + + +#: Реестр: имя enum в DBML (= имя типа в PostgreSQL) → класс. +ENUMS: dict[str, type[enum.Enum]] = { + "entity_type": EntityType, + "related_group_kind": RelatedGroupKind, + "account_kind": AccountKind, + "account_row_type": AccountRowType, + "business_segment": BusinessSegment, + "document_type": DocumentType, + "file_type": FileType, + "recognition_pipeline": RecognitionPipeline, + "period_quarter": PeriodQuarter, + "file_status": FileStatus, + "document_status": DocumentStatus, + "s3_bucket_role": S3BucketRole, + "recognition_status": RecognitionStatus, + "statement_type": StatementType, + "osv_group_type": OsvGroupType, + "osv_data_type": OsvDataType, + "consolidation_scenario": ConsolidationScenario, + "consolidation_approach": ConsolidationApproach, + "consolidation_status": ConsolidationStatus, + "correction_status": CorrectionStatus, + "document_check_result": DocumentCheckResult, + "le_history_event": LeHistoryEvent, + "ex_snapshot_status": ExSnapshotStatus, + "permission_object": PermissionObject, + "permission_action": PermissionAction, + "audit_section": AuditSection, + "audit_action": AuditAction, +} + + +def pg_enum(name: str) -> SAEnum: + """Тип колонки для enum DBML `name`: нативный PG-enum `dkb.`, значения — `.value` членов. + + Создание типа — поведение SQLAlchemy по умолчанию (`create_type=True`, `checkfirst`): `Base.metadata.create_all` + создаёт типы сам; миграция 0001 создаёт их явно. + """ + return SAEnum( + ENUMS[name], + name=name, + schema=SCHEMA, + native_enum=True, + values_callable=lambda e: [m.value for m in e], + ) diff --git a/api/tests/dbml_helpers.py b/api/tests/dbml_helpers.py new file mode 100644 index 0000000..92216a8 --- /dev/null +++ b/api/tests/dbml_helpers.py @@ -0,0 +1,24 @@ +"""Поиск и разбор DBML-модели для тестов (переиспользуется тестами чётности ORM и DBML).""" + +import os +from pathlib import Path + +import pytest +from pydbml import PyDBML + +DBML_FILE_NAME = "data-model-v5.13.dbml" + + +def dbml_path() -> Path: + """Путь к DBML: каталог docs из `ARM_KA_DOCS`, иначе соседний `../../docs` относительно `arm-core/api`.""" + docs = os.environ.get("ARM_KA_DOCS") + docs_dir = Path(docs) if docs else Path(__file__).resolve().parents[3] / "docs" + return docs_dir / "model" / DBML_FILE_NAME + + +def load_dbml() -> PyDBML: + """Разобрать DBML через pydbml; без файла — пропуск теста с явной причиной.""" + path = dbml_path() + if not path.is_file(): + pytest.skip(f"docs недоступен: нет {path} (задайте ARM_KA_DOCS или положите docs рядом с arm-core)") + return PyDBML.parse_file(path) diff --git a/api/tests/test_enums.py b/api/tests/test_enums.py new file mode 100644 index 0000000..21811fa --- /dev/null +++ b/api/tests/test_enums.py @@ -0,0 +1,57 @@ +"""Enum'ы ORM против блоков `Enum {}` DBML v5.13: имена, значения и их порядок (без БД).""" + +import enum +import re + +import pytest +from sqlalchemy import Enum as SAEnum + +from src.models import DBML_VERSION +from src.models.enums import ENUMS, pg_enum +from tests.dbml_helpers import dbml_path, load_dbml + +EXPECTED_ENUM_COUNT = 27 + + +def test_dbml_version_format() -> None: + assert re.fullmatch(r"\d+\.\d+", DBML_VERSION) + assert DBML_VERSION == "5.13" + assert f"v{DBML_VERSION}" in dbml_path().name + + +def test_enums_count_and_names_match_dbml() -> None: + dbml_enums = {e.name for e in load_dbml().enums} + assert len(dbml_enums) == EXPECTED_ENUM_COUNT + assert set(ENUMS) == dbml_enums + + +def test_enum_values_match_dbml_in_order() -> None: + for dbml_enum in load_dbml().enums: + expected = [item.name for item in dbml_enum.items] + actual = [m.value for m in ENUMS[dbml_enum.name]] + assert actual == expected, f"enum {dbml_enum.name}: ожидалось {expected}, фактически {actual}" + + +def test_enum_classes_are_str_enums_with_member_name_equal_value() -> None: + for name, cls in ENUMS.items(): + assert issubclass(cls, enum.Enum) and issubclass(cls, str), name + assert [m.name for m in cls] == [m.value for m in cls], name + + +def test_cyrillic_values_kept_verbatim() -> None: + assert [m.value for m in ENUMS["entity_type"]] == ["ЮЛ", "ИП"] + + +def test_pg_enum_factory_gives_dbml_named_type_in_dkb_schema() -> None: + for name, cls in ENUMS.items(): + sa_enum = pg_enum(name) + assert isinstance(sa_enum, SAEnum) + assert sa_enum.name == name + assert sa_enum.schema == "dkb" + assert sa_enum.native_enum is True + assert sa_enum.enums == [m.value for m in cls], name + + +def test_pg_enum_unknown_name_fails() -> None: + with pytest.raises(KeyError): + pg_enum("no_such_enum")