Andrey Epifancev 20983fb3d8 feat(files): API файлов, документов и строк ОСВ; подтверждение документа
GET /legal-entities/{id}/files (include_superseded, по убыванию uploaded_at),
GET /files/{id}, GET /documents/{id} (group_schema листа, warnings — R-6),
GET /documents/{id}/entries (limit ≤ 2000, по умолчанию 500),
POST /documents/{id}/confirm (DOCUMENT_EDIT; блокировка файла, затем документа;
409 FILE_SUPERSEDED / ALREADY_CONFIRMED / DOCUMENT_NOT_CONFIRMABLE; аудит
DOCUMENT_CONFIRM). Видимость — через ЮЛ-владельца, чужое — 404 как несуществующее.
Список файлов без N+1: файлы, текущие задания и документы — пакетными запросами.
JobBrief.mapping_error — причина ERROR файла по маппингу.

Тесты: сценарии documents-api, гонка подтверждения с автозаменой (Review Focus 5),
make_entries_from_result (R-2), реальная проверка уникальности документа (T2.2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 23:34:35 +04:00

130 lines
6.7 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; design D-11).
SQL без бизнес-правил; видимость — через ЮЛ-владельца (`src.domain.policy.legal_entities_visible`): файл или документ
вне видимости неотличим от несуществующего (`None`). Связей `relationship()` в моделях нет, поэтому задания и документы
страницы файлов читаются пакетно (`current_jobs`, `documents_by_file`) — число запросов не зависит от числа файлов.
Функции не делают `commit`: транзакцией запроса управляет `SessionDep`.
"""
import uuid
from collections.abc import Sequence
from typing import Any
from sqlalchemy import Select, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.policy import VisibilityScope, legal_entities_visible
from src.models.directories import LegalEntity
from src.models.enums import FileStatus
from src.models.files import Document, RecognitionJob, StoredFile
from src.models.osv import OsvEntry
def _visible_files(stmt: Select[Any], scope: VisibilityScope) -> Select[Any]:
"""Файлы, чьё ЮЛ-владелец в области видимости (`stmt` ссылается на `StoredFile`)."""
return legal_entities_visible(stmt.join(LegalEntity, LegalEntity.id == StoredFile.legal_entity_id), scope)
def _visible_documents(stmt: Select[Any], scope: VisibilityScope) -> Select[Any]:
"""Документы, чьё ЮЛ-владелец в области видимости (`stmt` ссылается на `Document`)."""
return legal_entities_visible(stmt.join(LegalEntity, LegalEntity.id == Document.legal_entity_id), scope)
async def list_files( # noqa: PLR0913 - фильтр и пагинация списка передаются явными именованными аргументами
session: AsyncSession,
scope: VisibilityScope,
entity_id: uuid.UUID,
*,
include_superseded: bool,
limit: int,
offset: int,
) -> tuple[list[StoredFile], int]:
"""Страница видимых файлов ЮЛ по убыванию `uploaded_at` (затем `id`) и общее число по фильтру; `SUPERSEDED` — только
при `include_superseded`."""
def filtered(stmt: Select[Any]) -> Select[Any]:
stmt = _visible_files(stmt, scope).where(StoredFile.legal_entity_id == entity_id)
if not include_superseded:
stmt = stmt.where(StoredFile.status != FileStatus.SUPERSEDED)
return stmt
total = (await session.execute(filtered(select(func.count(StoredFile.id))))).scalar_one()
page_stmt = (
filtered(select(StoredFile))
.order_by(StoredFile.uploaded_at.desc().nulls_last(), StoredFile.id)
.limit(limit)
.offset(offset)
)
files = list((await session.execute(page_stmt)).scalars().all())
return files, total
async def get_file(session: AsyncSession, scope: VisibilityScope, file_id: uuid.UUID) -> StoredFile | None:
"""Файл по id; `None` и для несуществующего, и для файла ЮЛ вне видимости."""
stmt = _visible_files(select(StoredFile).where(StoredFile.id == file_id), scope)
return (await session.execute(stmt)).scalar_one_or_none()
async def current_jobs(session: AsyncSession, files: Sequence[StoredFile]) -> dict[uuid.UUID, RecognitionJob]:
"""Текущие задания файлов (`stored_file.current_recognition_job_id`) одним запросом: `file_id → задание`."""
job_ids = [file.current_recognition_job_id for file in files if file.current_recognition_job_id is not None]
if not job_ids:
return {}
jobs = (await session.execute(select(RecognitionJob).where(RecognitionJob.id.in_(job_ids)))).scalars().all()
return {job.file_id: job for job in jobs}
async def documents_by_file(session: AsyncSession, file_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, list[Document]]:
"""Документы файлов одним запросом: `file_id → документы` (по типу, счёту, `locator`)."""
if not file_ids:
return {}
stmt = (
select(Document)
.where(Document.stored_file_id.in_(file_ids))
.order_by(Document.document_type, Document.osv_account_code, Document.locator, Document.id)
)
grouped: dict[uuid.UUID, list[Document]] = {file_id: [] for file_id in file_ids}
for document in (await session.execute(stmt)).scalars():
grouped[document.stored_file_id].append(document)
return grouped
async def get_document(session: AsyncSession, scope: VisibilityScope, document_id: uuid.UUID) -> Document | None:
"""Документ по id; `None` и для несуществующего, и для документа ЮЛ вне видимости."""
stmt = _visible_documents(select(Document).where(Document.id == document_id), scope)
return (await session.execute(stmt)).scalar_one_or_none()
async def list_entries(
session: AsyncSession, document_id: uuid.UUID, *, limit: int, offset: int
) -> tuple[list[OsvEntry], int]:
"""Страница строк документа по возрастанию `row_number` и их общее число (видимость документа — у вызывающего)."""
total = (
await session.execute(select(func.count(OsvEntry.id)).where(OsvEntry.document_id == document_id))
).scalar_one()
stmt = (
select(OsvEntry)
.where(OsvEntry.document_id == document_id)
.order_by(OsvEntry.row_number, OsvEntry.id)
.limit(limit)
.offset(offset)
)
return list((await session.execute(stmt)).scalars().all()), total
async def lock_file(session: AsyncSession, file_id: uuid.UUID) -> StoredFile | None:
"""`SELECT stored_file … FOR UPDATE` с перечитыванием (объект в identity map мог устареть)."""
stmt = (
select(StoredFile).where(StoredFile.id == file_id).with_for_update().execution_options(populate_existing=True)
)
return (await session.execute(stmt)).scalar_one_or_none()
async def lock_document(session: AsyncSession, document_id: uuid.UUID) -> Document | None:
"""`SELECT document … FOR UPDATE` с перечитыванием; вызывать после `lock_file` его файла (порядок блокировок)."""
stmt = (
select(Document).where(Document.id == document_id).with_for_update().execution_options(populate_existing=True)
)
return (await session.execute(stmt)).scalar_one_or_none()