arm-core/api/src/models/files.py
Andrey Epifancev 2db0a66a86 feat(models): DBML v5.19 — колонки форм, document_issue, file_ordinal, crm_id ГСК (миграция 0007)
Модели и upgrade ревизии 0007_dbml_v5_19: пять enum'ов, колонки строк/значений/колонок/шапок форм,
document.file_ordinal (уникален в файле, уникальность типа только для не-форм), recognition_job.statements_version,
related_group.crm_id, таблица document_issue; заполнение по умолчанию (row_index, needs_review, header_source и др.).
delete_file_statements удаляет document_issue; make_document берёт следующий file_ordinal; маппер пока нумерует
документы по порядку создания. downgrade — заглушка (задача A2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 19:04:40 +04:00

175 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Часть 04 DBML v5.19 «Файлы / распознавание»: реестр 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__ = (
# v5.19: одна ОСВ по счёту / одна расшифровка / один прочий на файл; формы ББ/ОФР одного типа могут
# повторяться (Р-10). 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,
postgresql_where=text("document_type NOT IN ('BALANCE', 'INCOME')"),
),
# v5.19: номер документа в documents[] результата уникален в файле
Index("uq_document_stored_file_id_file_ordinal", "stored_file_id", "file_ordinal", unique=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))
# v5.19: индекс документа в documents[] результата (с 0); несозданный документ номер не занимает
file_ordinal: Mapped[int] = mapped_column(SmallInteger, nullable=False, server_default=text("0"))
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)
# v5.19: service.statements_version результата — коммит копии общего кода arm_statements
statements_version: Mapped[str | None] = mapped_column(String)