"""Документы первичных файлов: карточка, строки ОСВ, подтверждение (spec documents-api «Документ и строки», «Подтверждение документа»; audit-trail `DOCUMENT_CONFIRM`; design D-11, R-6) и сборка карточки файла (`file_out`). Видимость — через ЮЛ-владельца (`repositories.files`); документ вне видимости — тот же 404, что несуществующий. Подтверждение и автозамена (`services.result_mapping.supersede_previous`) могут идти одновременно. Порядок блокировок подтверждения — `stored_file … FOR UPDATE` → `document … FOR NO KEY UPDATE` (R-3) → advisory ГК → версии консолидации «в работе» по `id` (`consolidation_documents.on_document_confirmed`, D-9, R-2): автозамена блокирует файлы (новый, затем старые по id), затем advisory ГК и версии, документы не блокирует, поэтому взаимной блокировки нет. Файл и документ перечитываются под блокировкой: файл, заменённый параллельной автозаменой, даёт 409 `FILE_SUPERSEDED` (Review Focus 5). """ import uuid from collections.abc import Sequence from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.audit import RequestAudit from src.common.clock import utc_now from src.common.exceptions import ConflictError, NotFoundError from src.domain.policy import VisibilityScope from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry from src.repositories import files as files_repo from src.schemas.documents import DocumentOut, EntriesPageParams, GroupOut from src.schemas.files import DocumentBrief, FileOut, JobBrief from src.services import consolidation_documents from src.services.result_mapping import MAPPING_ERROR_KEY, MAPPING_WARNINGS_KEY DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего OBJECT_TYPE = "document" WARNINGS_KEY = "warnings" # `recognition_job.metrics.warnings` — предупреждения сервиса распознавания FILE_SUPERSEDED = "FILE_SUPERSEDED" ALREADY_CONFIRMED = "ALREADY_CONFIRMED" DOCUMENT_NOT_CONFIRMABLE = "DOCUMENT_NOT_CONFIRMABLE" CONFIRMABLE_STATUSES = frozenset({DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK}) def _metrics(job: RecognitionJob | None) -> dict[str, Any]: return job.metrics if job is not None and isinstance(job.metrics, dict) else {} def _strings(value: Any) -> list[str]: """Список строк из JSONB (`metrics.*`); иное содержимое — пустой список.""" return [item for item in value if isinstance(item, str)] if isinstance(value, list) else [] def job_brief(job: RecognitionJob) -> JobBrief: """Текущее задание файла для карточки: статус, ошибки сервиса и маппинга, предупреждения.""" metrics = _metrics(job) mapping_error = metrics.get(MAPPING_ERROR_KEY) return JobBrief( id=job.id, status=job.status, error_code=job.error_code, error_message=job.error_message, mapping_error=mapping_error if isinstance(mapping_error, str) else None, warnings=_strings(metrics.get(WARNINGS_KEY)), mapping_warnings=_strings(metrics.get(MAPPING_WARNINGS_KEY)), finished_at=job.finished_at, ) def file_out(file: StoredFile, job: RecognitionJob | None, documents: Sequence[Document]) -> FileOut: """Карточка файла (spec documents-api «Файлы организации и карточка файла»).""" return FileOut( id=file.id, legal_entity_id=file.legal_entity_id, original_name=file.original_name, file_type=file.file_type, status=file.status, version_no=file.version_no, size_bytes=file.size_bytes, content_hash=file.content_hash, uploaded_at=file.uploaded_at, uploaded_by=file.uploaded_by, superseded_by_file_id=file.superseded_by_file_id, job=job_brief(job) if job is not None else None, documents=[DocumentBrief.model_validate(document) for document in documents], ) def _group_schema(job: RecognitionJob | None, locator: str | None) -> list[GroupOut] | None: """Группировки листа документа: элемент `job.group_schema` с тем же `locator` (R-6); нет — `None`.""" if job is None or locator is None or not isinstance(job.group_schema, list): return None for sheet in job.group_schema: if isinstance(sheet, dict) and sheet.get("locator") == locator: groups = sheet.get("groups") return [GroupOut.model_validate(group) for group in groups] if isinstance(groups, list) else [] return None def document_out(document: Document, job: RecognitionJob | None) -> DocumentOut: """Карточка документа; `job` — текущее задание его файла (схема группировок и предупреждения маппинга).""" return DocumentOut( id=document.id, file_id=document.stored_file_id, legal_entity_id=document.legal_entity_id, document_type=document.document_type, osv_account_code=document.osv_account_code, period_year=document.period_year, period_quarter=document.period_quarter, is_cumulative=document.is_cumulative, locator=document.locator, status=document.status, period_check=document.period_check, counterparty_check=document.counterparty_check, confirmed_by=document.confirmed_by, confirmed_at=document.confirmed_at, group_schema=_group_schema(job, document.locator), # ASSUMPTION(R-6): в DBML нет привязки предупреждения к документу — отдаются предупреждения маппинга файла warnings=_strings(_metrics(job).get(MAPPING_WARNINGS_KEY)), ) class DocumentService: """Чтение и подтверждение документов от имени пользователя запроса (права — на маршруте, видимость — здесь).""" def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: self._session = session self._audit = audit self._scope = scope async def _visible(self, document_id: uuid.UUID) -> Document: document = await files_repo.get_document(self._session, self._scope, document_id) if document is None: raise NotFoundError(DOCUMENT_NOT_FOUND) return document async def _current_job(self, file: StoredFile | None) -> RecognitionJob | None: if file is None or file.current_recognition_job_id is None: return None return await self._session.get(RecognitionJob, file.current_recognition_job_id) async def get(self, document_id: uuid.UUID) -> DocumentOut: """Карточка видимого документа; иначе 404.""" document = await self._visible(document_id) file = await self._session.get(StoredFile, document.stored_file_id) return document_out(document, await self._current_job(file)) async def entries(self, document_id: uuid.UUID, page: EntriesPageParams) -> tuple[list[OsvEntry], int]: """Страница строк видимого документа по `row_number`; иначе 404.""" document = await self._visible(document_id) return await files_repo.list_entries(self._session, document.id, limit=page.limit, offset=page.offset) async def confirm(self, document_id: uuid.UUID) -> DocumentOut: """`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED` (`confirmed_by/at`), аудит `DOCUMENT_CONFIRM`. 409: файл `SUPERSEDED` — `FILE_SUPERSEDED`; уже `CONFIRMED` — `ALREADY_CONFIRMED`; иной статус — `DOCUMENT_NOT_CONFIRMABLE`. Порядок блокировок и перечитывание — см. docstring модуля. В той же транзакции документ привязывается к подходящим версиям «в работе», версии из `INITIATED`/`DOCUMENTS_PROCESSING` переходят в `DATA_CORRECTION` (`on_document_confirmed`, spec documents-api «Подтверждение документа»). """ visible = await self._visible(document_id) file = await files_repo.lock_file(self._session, visible.stored_file_id) document = await files_repo.lock_document(self._session, document_id) if file is None or document is None: # удалены параллельно, пока ждали блокировку raise NotFoundError(DOCUMENT_NOT_FOUND) if file.status is FileStatus.SUPERSEDED: raise ConflictError( FILE_SUPERSEDED, "файл документа заменён новой версией: подтвердите документ нового файла" ) if document.status is DocumentStatus.CONFIRMED: raise ConflictError(ALREADY_CONFIRMED, "документ уже подтверждён") if document.status not in CONFIRMABLE_STATUSES: raise ConflictError( DOCUMENT_NOT_CONFIRMABLE, f"документ в статусе {document.status.value} подтвердить нельзя: нужен RECOGNIZED или NEEDS_CHECK", ) before = document.status now = utc_now() document.status = DocumentStatus.CONFIRMED document.confirmed_by = self._audit.user_id document.confirmed_at = now document.updated_at = now await self._session.flush() await consolidation_documents.on_document_confirmed( self._session, self._audit.service, document=document, user_id=self._audit.user_id, now=now ) await self._audit.record( action=AuditAction.DOCUMENT_CONFIRM, section=AuditSection.PRIMARY_DOCUMENTS, object_type=OBJECT_TYPE, object_id=document.id, before_value={"status": before.value}, after_value={"status": DocumentStatus.CONFIRMED.value}, ) return document_out(document, await self._current_job(file))