arm-core/api/src/models/files.py
Andrey Epifancev 4cc926c033 КПП юридического лица: legal_entity.kpp, миграция 0005, API, импорт (DBML v5.17, #45)
- модели LegalEntity.kpp / LegalEntityHistory.kpp (последней колонкой), миграция 0005_dbml_v5_17 (upgrade/downgrade);
- domain/kpp.py: 9 символов (4 цифры, 2 цифры/латинские A-Z, 3 цифры), у ИП запрещён, иначе 422 INVALID_KPP;
- схемы Create/Update/Out/Detail/HistoryOut, PATCH пишет снимок history и аудит before/after с kpp, поиск q по КПП;
- импорт справочника: необязательная колонка КПП (синоним kpp), код INVALID_KPP, update обновляет непустой КПП;
- dev-seed: необязательный ключ kpp (значений в данных нет, не выдумываем);
- сверка с DBML, Makefile, README, CLAUDE.md, openspec/config.yaml — на v5.17.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
2026-10-07 18:51:50 +04:00

162 lines
8.9 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.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"),
)
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))
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)