167 lines
9.6 KiB
Python
167 lines
9.6 KiB
Python
"""Часть 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)
|