arm-core/api/src/services/documents.py
Andrey Epifancev 36bc966bf6 feat(consolidation): привязка документов к версиям при подтверждении, подхват и отвязка при автозамене
- consolidation_documents: чистая fits (D-5), bind_confirmed (подхват, самый поздний confirmed_at на ключ R-1),
  unbind_entity, on_document_confirmed (привязка ко всем подходящим версиям «в работе», переход в DATA_CORRECTION
  через transition), on_documents_superseded (отвязка документов заменённых файлов).
- DocumentService.confirm вызывает хук привязки; supersede_previous снимает привязки и пишет
  FILE_REPLACE.after_value.unbound_version_ids; create и PUT …/members подхватывают по-настоящему.
- R-2: advisory-блокировка привязок ГК (lock_group_bindings) в create и lock_versions_for_entity закрывает
  гонку «создание версии ∥ подтверждение/автозамена».
- R-3: lock_document и lock_file_documents — FOR NO KEY UPDATE (совместимо с FOR KEY SHARE вставки привязки).
- Тесты: таблица fits, сценарии documents-api и primary-files, две версии одного документа, гонки на двух
  сессиях (confirm ∥ members в обе стороны, create ∥ confirm в обе стороны, цикл блокировок документа и версии).
- design.md: решения №30–№36; plan и tasks.md (3.1) отмечены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 04:57:15 +04:00

192 lines
11 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.

"""Документы первичных файлов: карточка, строки ОСВ, подтверждение (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))