diff --git a/api/src/models/__init__.py b/api/src/models/__init__.py index faac666..8071b68 100644 --- a/api/src/models/__init__.py +++ b/api/src/models/__init__.py @@ -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"] diff --git a/api/src/models/access.py b/api/src/models/access.py new file mode 100644 index 0000000..cf8f275 --- /dev/null +++ b/api/src/models/access.py @@ -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) diff --git a/api/src/models/accounting.py b/api/src/models/accounting.py new file mode 100644 index 0000000..def7fd6 --- /dev/null +++ b/api/src/models/accounting.py @@ -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")) diff --git a/api/src/models/base.py b/api/src/models/base.py index 325b5bb..16839bc 100644 --- a/api/src/models/base.py +++ b/api/src/models/base.py @@ -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) diff --git a/api/src/models/directories.py b/api/src/models/directories.py new file mode 100644 index 0000000..7b0f0a8 --- /dev/null +++ b/api/src/models/directories.py @@ -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")) diff --git a/api/src/models/enums.py b/api/src/models/enums.py index 67cc317..2b621c8 100644 --- a/api/src/models/enums.py +++ b/api/src/models/enums.py @@ -10,7 +10,7 @@ import enum from sqlalchemy import Enum as SAEnum -SCHEMA = "dkb" +from .base import SCHEMA class EntityType(enum.StrEnum):