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