feat: ORM-модели частей 01–03 DBML v5.13 (справочники, доступ, учёт), naming_convention в MetaData

16 таблиц: directories, access, accounting; схема dkb, детерминированные имена ограничений.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-03 19:05:04 +04:00
parent a272d6873a
commit fb2b9ef9fc
6 changed files with 338 additions and 5 deletions

View File

@ -1,8 +1,12 @@
"""ORM-модели. Источник истины — docs/model/data-model-v5.13.dbml; заполняется в Срезе 0."""
"""ORM-модели. Источник истины — docs/model/data-model-v5.13.dbml; заполняется в Срезе 0.
Импорт всех модулей моделей обязателен: он регистрирует таблицы в `Base.metadata` (create_all, alembic).
"""
from . import access, accounting, directories
from .base import Base, UUIDPrimaryMixin
#: Версия DBML, которой соответствуют модели (сверяется с шапкой DBML в check_dbml_vs_orm).
DBML_VERSION = "5.13"
__all__ = ["DBML_VERSION", "Base", "UUIDPrimaryMixin"]
__all__ = ["DBML_VERSION", "Base", "UUIDPrimaryMixin", "access", "accounting", "directories"]

75
api/src/models/access.py Normal file
View File

@ -0,0 +1,75 @@
"""Часть 02 DBML v5.13 «Пользователи и доступ»: пользователи, роли, права как данные (`effective_permissions`)."""
import datetime
import uuid
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base, UUIDPrimaryMixin
from .enums import BusinessSegment, PermissionAction, PermissionObject, pg_enum
class AppUser(UUIDPrimaryMixin, Base):
"""Пользователь; аутентификация внешняя (JWT РАИСА, email → `app_user`)."""
__tablename__ = "app_user"
external_id: Mapped[str | None] = mapped_column(String, unique=True)
email: Mapped[str | None] = mapped_column(String, unique=True)
full_name: Mapped[str] = mapped_column(String, nullable=False)
employee_no: Mapped[str | None] = mapped_column(String)
regional_office_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("regional_office.id"), nullable=False
)
business_segment: Mapped[BusinessSegment | None] = mapped_column(pg_enum("business_segment"))
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
created_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
class Role(UUIDPrimaryMixin, Base):
"""Роль; полномочия — через `role_permission`, код роли в бизнес-логике не проверяется."""
__tablename__ = "role"
code: Mapped[str] = mapped_column(String, nullable=False, unique=True)
name: Mapped[str] = mapped_column(String, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
class Permission(UUIDPrimaryMixin, Base):
"""Справочник прав «объект × действие» (seed, привязан к коду)."""
__tablename__ = "permission"
__table_args__ = (Index("uq_permission_object_type_action", "object_type", "action", unique=True),)
code: Mapped[str] = mapped_column(String, nullable=False, unique=True)
object_type: Mapped[PermissionObject] = mapped_column(pg_enum("permission_object"), nullable=False)
action: Mapped[PermissionAction] = mapped_column(pg_enum("permission_action"), nullable=False)
name: Mapped[str] = mapped_column(String, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
class RolePermission(Base):
"""Матрица доступов как данные; составной PK `(role_id, permission_id)`, суррогатного `id` нет."""
__tablename__ = "role_permission"
role_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("role.id"), primary_key=True)
permission_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("permission.id"), primary_key=True)
granted_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id"))
granted_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
class UserRole(UUIDPrimaryMixin, Base):
"""Назначение ролей пользователю (M:N, current-state); история — `audit_event(USER_ROLE_CHANGE)`."""
__tablename__ = "user_role"
__table_args__ = (Index("uq_user_role_user_id_role_id", "user_id", "role_id", unique=True),)
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id"), nullable=False)
role_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("role.id"), nullable=False)

View File

@ -0,0 +1,64 @@
"""Часть 03 DBML v5.13 (учёт): план счетов и правила элиминации ВГО (Прил. 3/4 ТЗ как данные)."""
import datetime
import uuid
from sqlalchemy import Boolean, Date, ForeignKey, Index, Integer, SmallInteger, String, Text, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base, UUIDPrimaryMixin
from .enums import AccountKind, AccountRowType, StatementType, pg_enum
class ChartOfAccount(UUIDPrimaryMixin, Base):
"""План счетов; `code` — нормализованный (60.01, 76.07.1)."""
__tablename__ = "chart_of_account"
code: Mapped[str] = mapped_column(String, nullable=False, unique=True)
parent_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("chart_of_account.id"))
name: Mapped[str] = mapped_column(String, nullable=False)
kind: Mapped[AccountKind | None] = mapped_column(pg_enum("account_kind"))
row_type: Mapped[AccountRowType] = mapped_column(pg_enum("account_row_type"), nullable=False)
is_off_balance: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
class EliminationRule(UUIDPrimaryMixin, Base):
"""Правило элиминации (Прил. 3 — ББ, Прил. 4 — ОФР), версионируется; состав счетов — `elimination_rule_account`."""
__tablename__ = "elimination_rule"
__table_args__ = (
Index(
"uq_elimination_rule_statement_type_rule_no_version_no",
"statement_type",
"rule_no",
"version_no",
unique=True,
),
)
statement_type: Mapped[StatementType] = mapped_column(pg_enum("statement_type"), nullable=False)
rule_no: Mapped[int] = mapped_column(SmallInteger, nullable=False)
article_1_line: Mapped[str] = mapped_column(String, nullable=False)
article_2_line: Mapped[str] = mapped_column(String, nullable=False)
side_1: Mapped[str] = mapped_column(String, nullable=False)
measure: Mapped[str] = mapped_column(String, nullable=False)
elimination_mode: Mapped[str] = mapped_column(String, nullable=False)
vat_handling: Mapped[str | None] = mapped_column(String)
excise_adjust: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
comment: Mapped[str | None] = mapped_column(Text)
version_no: Mapped[int] = mapped_column(Integer, nullable=False)
valid_from: Mapped[datetime.date] = mapped_column(Date, nullable=False)
valid_to: Mapped[datetime.date | None] = mapped_column(Date)
class EliminationRuleAccount(UUIDPrimaryMixin, Base):
"""Счета и субсчета сторон правила; `is_excluded` — исключение внутри маски («76 кроме 76.7.1»)."""
__tablename__ = "elimination_rule_account"
rule_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("elimination_rule.id"), nullable=False)
company_no: Mapped[int] = mapped_column(SmallInteger, nullable=False)
account_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("chart_of_account.id"), nullable=False)
is_excluded: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))

View File

@ -1,14 +1,35 @@
"""Базовый класс ORM: схема `dkb`, детерминированные имена ограничений и первичный ключ uuid."""
import uuid
from sqlalchemy import MetaData
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, declarative_mixin, mapped_column
#: Схема PostgreSQL, в которой живёт вся модель данных (DBML v5.13).
SCHEMA = "dkb"
#: Стандартная схема имён SQLAlchemy. Нужна, чтобы ORM, миграция 0001 и `alembic autogenerate` давали одни и те же
#: имена ограничений и `downgrade` был детерминирован. Для составных/partial-индексов, где конвенция даёт
#: неоднозначное или слишком длинное имя (лимит PostgreSQL — 63 символа), имена задаются явно в `__table_args__`.
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(schema="dkb")
metadata = MetaData(schema=SCHEMA, naming_convention=NAMING_CONVENTION)
@declarative_mixin
class UUIDPrimaryMixin:
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
"""`id uuid [pk]` из DBML: значение генерирует приложение (uuid4), серверного default в БД нет.
`sort_order=-1` — `id` первой колонкой, как в DBML (иначе колонки миксина идут после колонок класса).
"""
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, sort_order=-1)

View File

@ -0,0 +1,169 @@
"""Часть 01 DBML v5.13 «Справочники»: филиалы, ГК и ЮЛ (SCD4), алиасы, ГСК/ГСЗ (SCD2)."""
import datetime
import uuid
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, String, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base, UUIDPrimaryMixin
from .enums import EntityType, LeHistoryEvent, RelatedGroupKind, pg_enum
class RegionalOffice(UUIDPrimaryMixin, Base):
"""Филиал: ГО и региональные филиалы (DECISION-3: ГО — обычная запись с `is_head_office`)."""
__tablename__ = "regional_office"
name: Mapped[str] = mapped_column(String, nullable=False, unique=True)
is_head_office: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
class CompanyGroup(UUIDPrimaryMixin, Base):
"""ГК — текущее состояние (SCD4); история — `company_group_history`."""
__tablename__ = "company_group"
code: Mapped[str] = mapped_column(String, nullable=False, unique=True)
crm_id: Mapped[str | None] = mapped_column(String, unique=True)
name: Mapped[str] = mapped_column(String, nullable=False)
base_regional_office_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("regional_office.id")
)
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
valid_from: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id"))
created_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
class CompanyGroupHistory(UUIDPrimaryMixin, Base):
"""Прошлые снимки ГК (SCD4)."""
__tablename__ = "company_group_history"
__table_args__ = (
Index("uq_company_group_history_company_group_id_valid_from", "company_group_id", "valid_from", unique=True),
)
company_group_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("company_group.id"), nullable=False
)
crm_id: Mapped[str | None] = mapped_column(String)
name: Mapped[str | None] = mapped_column(String)
base_regional_office_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
valid_from: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
valid_to: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
changed_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id"))
class LegalEntity(UUIDPrimaryMixin, Base):
"""Организация (ЮЛ/ИП) — текущее состояние (SCD4); `company_group_id IS NULL` = монозаёмщик."""
__tablename__ = "legal_entity"
inn: Mapped[str] = mapped_column(String, nullable=False, unique=True)
crm_id: Mapped[str | None] = mapped_column(String, unique=True)
entity_type: Mapped[EntityType | None] = mapped_column(pg_enum("entity_type"))
name: Mapped[str] = mapped_column(String, nullable=False)
company_group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("company_group.id"))
regional_office_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("regional_office.id"))
close_date: Mapped[datetime.date | None] = mapped_column(Date)
valid_from: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False)
updated_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id"))
created_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
class LegalEntityHistory(UUIDPrimaryMixin, Base):
"""Прошлые снимки ЮЛ (SCD4) и события для архива ГК."""
__tablename__ = "legal_entity_history"
__table_args__ = (
Index("uq_legal_entity_history_legal_entity_id_valid_from", "legal_entity_id", "valid_from", unique=True),
)
legal_entity_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False
)
inn: Mapped[str | None] = mapped_column(String)
crm_id: Mapped[str | None] = mapped_column(String)
entity_type: Mapped[EntityType | None] = mapped_column(pg_enum("entity_type"))
name: Mapped[str | None] = mapped_column(String)
company_group_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
regional_office_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
close_date: Mapped[datetime.date | None] = mapped_column(Date)
event_type: Mapped[LeHistoryEvent | None] = mapped_column(pg_enum("le_history_event"))
effective_date: Mapped[datetime.date | None] = mapped_column(Date)
valid_from: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
valid_to: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
changed_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id"))
class LegalEntityAlias(UUIDPrimaryMixin, Base):
"""Словарь соответствий наименований контрагентов (ТЗ 6.5)."""
__tablename__ = "legal_entity_alias"
__table_args__ = (
Index(
"uq_legal_entity_alias_legal_entity_id_normalized_alias", "legal_entity_id", "normalized_alias", unique=True
),
Index("ix_legal_entity_alias_normalized_alias", "normalized_alias"),
)
legal_entity_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False
)
alias: Mapped[str] = mapped_column(String, nullable=False)
normalized_alias: Mapped[str] = mapped_column(String, nullable=False)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id"))
created_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True))
class RelatedGroup(UUIDPrimaryMixin, Base):
"""ГСК/ГСЗ — текущее состояние; иерархия групп не поддерживается (ТЗ 6.1)."""
__tablename__ = "related_group"
code: Mapped[str] = mapped_column(String, nullable=False, unique=True)
company_group_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("company_group.id"), nullable=False
)
kind: Mapped[RelatedGroupKind] = mapped_column(pg_enum("related_group_kind"), nullable=False)
name: Mapped[str] = mapped_column(String, nullable=False)
created_date: Mapped[datetime.date | None] = mapped_column(Date)
class RelatedGroupMember(UUIDPrimaryMixin, Base):
"""Состав ГСК/ГСЗ — SCD2; исключение = закрытие версии (`is_current=false`)."""
__tablename__ = "related_group_member"
__table_args__ = (
# (related_group_id, legal_entity_id): partial UNIQUE WHERE is_current
Index(
"uq_related_group_member_group_entity_current",
"related_group_id",
"legal_entity_id",
unique=True,
postgresql_where=text("is_current"),
),
# legal_entity_id: partial UNIQUE WHERE is_current — ЮЛ только в одной ГСК или ГСЗ (ТЗ 6.1, ANL-030)
Index(
"uq_related_group_member_entity_current",
"legal_entity_id",
unique=True,
postgresql_where=text("is_current"),
),
)
related_group_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("related_group.id"), nullable=False
)
legal_entity_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("legal_entity.id"), 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)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id"))

View File

@ -10,7 +10,7 @@ import enum
from sqlalchemy import Enum as SAEnum
SCHEMA = "dkb"
from .base import SCHEMA
class EntityType(enum.StrEnum):