"""Часть 04 DBML v5.17 «Файлы / распознавание»: реестр 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` создаёт таблицы без этого FK и добавляет его отдельным `ALTER TABLE`; имя FK задано явно (то же имя — в миграции 0001). """ import datetime import uuid from typing import Any from sqlalchemy import BigInteger, Boolean, Date, DateTime, 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, nullable_jsonb from .enums import ( DocumentCheckResult, DocumentStatus, DocumentType, FileStatus, FileType, PeriodQuarter, RecognitionPipeline, RecognitionStatus, S3BucketRole, pg_enum, ) class S3Object(UUIDPrimaryMixin, Base): """Реестр объектов S3: единая точка ключей для lifecycle/ретеншна (ТЗ 8.4) и дедупа.""" __tablename__ = "s3_object" __table_args__ = (Index("uq_s3_object_bucket_key", "bucket", "key", unique=True),) bucket_role: Mapped[S3BucketRole] = mapped_column(pg_enum("s3_bucket_role"), nullable=False) bucket: Mapped[str] = mapped_column(String, nullable=False) key: Mapped[str] = mapped_column(String, nullable=False) content_type: Mapped[str | None] = mapped_column(String) size_bytes: Mapped[int | None] = mapped_column(BigInteger) sha256: Mapped[str | None] = mapped_column(String) version_id: Mapped[str | None] = mapped_column(String) created_by: Mapped[str | None] = mapped_column(String) created_at: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), nullable=False) expires_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) archived_to: Mapped[str | None] = mapped_column(String) class StoredFile(UUIDPrimaryMixin, Base): """Физический файл одной загрузки; что внутри — `document` (1:N). Первичка не редактируется (ТЗ 8.2).""" __tablename__ = "stored_file" __table_args__ = ( Index("ix_stored_file_legal_entity_id_status", "legal_entity_id", "status"), # v5.15 п.4 (OQ-117): проверка FK при удалении recognition_job / файла Index("ix_stored_file_current_recognition_job_id", "current_recognition_job_id"), # v5.18: рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ») Index("ix_stored_file_legal_entity_id_consolidation_id", "legal_entity_id", "consolidation_id"), ) legal_entity_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False ) file_type: Mapped[FileType] = mapped_column(pg_enum("file_type"), nullable=False) original_name: Mapped[str | None] = mapped_column(String) s3_object_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("s3_object.id"), nullable=False) content_hash: Mapped[str | None] = mapped_column(String) size_bytes: Mapped[int | None] = mapped_column(BigInteger) status: Mapped[FileStatus] = mapped_column(pg_enum("file_status"), nullable=False) version_no: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("1")) superseded_by_file_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("stored_file.id")) current_recognition_job_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey( "recognition_job.id", use_alter=True, name="fk_stored_file_current_recognition_job_id_recognition_job", ), ) uploaded_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) uploaded_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) # v5.18: консолидация (период ГК), в контексте которой файл загружен; NULL — без контекста (§9.5 п. 8). Последняя # колонка: миграция 0006 добавляет её ALTER TABLE, а check_dbml сверяет порядок consolidation_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("consolidation.id")) class Document(UUIDPrimaryMixin, Base): """Логический документ внутри файла (ББ / ОФР / ОСВ по счёту / расшифровка); создаётся ядром при маппинге.""" __tablename__ = "document" __table_args__ = ( # один ББ / один ОФР / одна ОСВ по счёту на файл: NULL в osv_account_code считается значением (PG15+) Index( "uq_document_file_type_account", "stored_file_id", "document_type", "osv_account_code", unique=True, postgresql_nulls_not_distinct=True, ), # чек-лист required_document_rule Index( "ix_document_legal_entity_period_status", "legal_entity_id", "document_type", "osv_account_code", "period_year", "period_quarter", "status", ), ) stored_file_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("stored_file.id"), nullable=False) legal_entity_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("legal_entity.id"), nullable=False ) document_type: Mapped[DocumentType] = mapped_column(pg_enum("document_type"), nullable=False) osv_account_code: Mapped[str | None] = mapped_column(String) period_year: Mapped[int | None] = mapped_column(SmallInteger) period_quarter: Mapped[PeriodQuarter | None] = mapped_column(pg_enum("period_quarter")) is_cumulative: Mapped[bool | None] = mapped_column(Boolean) reporting_date: Mapped[datetime.date | None] = mapped_column(Date) locator: Mapped[str | None] = mapped_column(String) status: Mapped[DocumentStatus] = mapped_column(pg_enum("document_status"), nullable=False) period_check: Mapped[DocumentCheckResult | None] = mapped_column(pg_enum("document_check_result")) counterparty_check: Mapped[DocumentCheckResult | None] = mapped_column(pg_enum("document_check_result")) confirmed_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("app_user.id")) confirmed_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) updated_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) class RecognitionJob(UUIDPrimaryMixin, Base): """Единственная точка обмена с NATS: запись = outbox (queued -> published), результат — inbox (дедуп).""" __tablename__ = "recognition_job" __table_args__ = (Index("uq_recognition_job_file_id_attempt", "file_id", "attempt", unique=True),) file_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("stored_file.id"), nullable=False) pipeline: Mapped[RecognitionPipeline] = mapped_column(pg_enum("recognition_pipeline"), nullable=False) request_subject: Mapped[str] = mapped_column(String, nullable=False) request_msg_id: Mapped[str | None] = mapped_column(String, unique=True) result_msg_id: Mapped[str | None] = mapped_column(String, unique=True) schema_version: Mapped[str] = mapped_column(String, nullable=False) service_name: Mapped[str | None] = mapped_column(String) service_version: Mapped[str | None] = mapped_column(String) status: Mapped[RecognitionStatus] = mapped_column(pg_enum("recognition_status"), nullable=False) attempt: Mapped[int] = mapped_column(SmallInteger, nullable=False, server_default=text("1")) max_attempts: Mapped[int] = mapped_column(SmallInteger, nullable=False, server_default=text("1")) requested_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) published_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) started_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) finished_at: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) reply_deadline: Mapped[datetime.datetime | None] = mapped_column(DateTime(timezone=True)) external_task_id: Mapped[str | None] = mapped_column(String) error_code: Mapped[str | None] = mapped_column(String) error_message: Mapped[str | None] = mapped_column(Text) result_s3_object_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("s3_object.id")) result_schema_version: Mapped[str | None] = mapped_column(String) group_schema: Mapped[Any | None] = mapped_column(nullable_jsonb()) metrics: Mapped[Any | None] = mapped_column(nullable_jsonb()) fields_total: Mapped[int | None] = mapped_column(Integer) fields_low_confidence: Mapped[int | None] = mapped_column(Integer) fields_unrecognized: Mapped[int | None] = mapped_column(Integer)