feat(documents): комментарии к документу и comments_count в карточке (D-11)

POST/GET /documents/{id}/comments, счётчик комментариев в карточке файла
и документа одним запросом на пачку документов.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 12:46:43 +04:00
parent 3f744b7eb9
commit c9bd620a18
12 changed files with 246 additions and 11 deletions

View File

@ -2,10 +2,11 @@
from fastapi import APIRouter
from src.api.endpoints.files import documents, read, statements, upload
from src.api.endpoints.files import comments, documents, read, statements, upload
router = APIRouter(tags=["files"])
router.include_router(upload.router)
router.include_router(read.router)
router.include_router(documents.router)
router.include_router(comments.router)
router.include_router(statements.router)

View File

@ -0,0 +1,46 @@
"""`/documents/{document_id}/comments` — комментарии к документу (spec documents-api «Комментарии к документу»; D-11).
Чтение — `DOCUMENT_VIEW`, добавление — `DOCUMENT_EDIT`; документ ЮЛ вне видимости — тот же 404, что несуществующий.
Правки и удаления комментариев нет; аудит комментариев не пишется (D-11).
"""
import uuid
from typing import Any
from fastapi import APIRouter, Depends, status
from src.api.dependencies.auth import require
from src.api.dependencies.files import DocumentServiceDep
from src.schemas.comments import CommentIn, CommentOut
from src.schemas.common import ErrorDetail
router = APIRouter(prefix="/documents")
NOT_FOUND: dict[int | str, dict[str, Any]] = {
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"}
}
@router.get(
"/{document_id}/comments",
response_model=list[CommentOut],
dependencies=[Depends(require("DOCUMENT_VIEW"))],
responses=NOT_FOUND,
summary="Комментарии к документу",
)
async def list_comments(document_id: uuid.UUID, service: DocumentServiceDep) -> list[CommentOut]:
"""Комментарии по возрастанию `created_at` с ФИО автора."""
return await service.comments(document_id)
@router.post(
"/{document_id}/comments",
response_model=CommentOut,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require("DOCUMENT_EDIT"))],
responses=NOT_FOUND,
summary="Добавить комментарий к документу",
)
async def add_comment(document_id: uuid.UUID, body: CommentIn, service: DocumentServiceDep) -> CommentOut:
"""Комментарий к документу в любом статусе (в том числе файла `SUPERSEDED`); автор — пользователь запроса."""
return await service.add_comment(document_id, body)

View File

@ -16,11 +16,15 @@ from sqlalchemy import Select, and_, delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.policy import VisibilityScope, legal_entities_visible
from src.models.access import AppUser
from src.models.audit_ex import Comment
from src.models.directories import LegalEntity
from src.models.enums import FileStatus, S3BucketRole
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
from src.models.osv import OsvEntry
COMMENT_OBJECT_TYPE = "document" # `comment.object_type` комментариев документа
def _visible_files(stmt: Select[Any], scope: VisibilityScope) -> Select[Any]:
"""Файлы, чьё ЮЛ-владелец в области видимости (`stmt` ссылается на `StoredFile`)."""
@ -91,6 +95,29 @@ async def documents_by_file(session: AsyncSession, file_ids: Sequence[uuid.UUID]
return grouped
async def comments_count_by_document(session: AsyncSession, document_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, int]:
"""Число комментариев документов одним запросом: `document_id → count` (документы без комментариев отсутствуют)."""
if not document_ids:
return {}
stmt = (
select(Comment.object_id, func.count(Comment.id))
.where(Comment.object_type == COMMENT_OBJECT_TYPE, Comment.object_id.in_(document_ids))
.group_by(Comment.object_id)
)
return {document_id: count for document_id, count in (await session.execute(stmt)).all()}
async def list_comments(session: AsyncSession, document_id: uuid.UUID) -> list[tuple[Comment, str]]:
"""Комментарии документа по `created_at` (затем `id`) вместе с ФИО автора."""
stmt = (
select(Comment, AppUser.full_name)
.join(AppUser, AppUser.id == Comment.author_id)
.where(Comment.object_type == COMMENT_OBJECT_TYPE, Comment.object_id == document_id)
.order_by(Comment.created_at, Comment.id)
)
return [(comment, full_name) for comment, full_name in (await session.execute(stmt)).all()]
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)

View File

@ -0,0 +1,32 @@
"""Схемы комментариев к документу (spec documents-api «Комментарии к документу»; design D-11)."""
import datetime
import uuid
from pydantic import BaseModel, ConfigDict, Field
MAX_COMMENT_LENGTH = 4000
class CommentIn(BaseModel):
"""Тело комментария: после обрезки пробелов 1–4000 символов; пустой текст и слишком длинный — 422."""
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
text: str = Field(min_length=1, max_length=MAX_COMMENT_LENGTH)
class CommentAuthorOut(BaseModel):
"""Автор комментария."""
id: uuid.UUID
full_name: str
class CommentOut(BaseModel):
"""Комментарий: текст, автор и время создания."""
id: uuid.UUID
text: str
author: CommentAuthorOut
created_at: datetime.datetime

View File

@ -53,6 +53,7 @@ class DocumentOut(BaseModel):
confirmed_at: datetime.datetime | None
group_schema: list[GroupOut] | None
warnings: list[str]
comments_count: int = 0 # число комментариев документа (D-11)
class EntryOut(BaseModel):

View File

@ -76,6 +76,7 @@ class DocumentBrief(BaseModel):
period_check: DocumentCheckResult | None
counterparty_check: DocumentCheckResult | None
locator: str | None
comments_count: int = 0 # число комментариев документа (D-11); в ORM-модели поля нет — задаётся при сборке
class FileOut(BaseModel):

View File

@ -11,7 +11,7 @@
"""
import uuid
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
@ -20,12 +20,15 @@ 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.access import AppUser
from src.models.audit_ex import Comment
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.models.statements import FinancialStatementMeta
from src.repositories import files as files_repo
from src.repositories import statements as statements_repo
from src.schemas.comments import CommentAuthorOut, CommentIn, CommentOut
from src.schemas.documents import DocumentOut, EntriesPageParams, GroupOut
from src.schemas.files import DocumentBrief, FileOut, JobBrief
from src.services import consolidation_documents
@ -67,8 +70,17 @@ def job_brief(job: RecognitionJob) -> JobBrief:
)
def file_out(file: StoredFile, job: RecognitionJob | None, documents: Sequence[Document]) -> FileOut:
"""Карточка файла (spec documents-api «Файлы организации и карточка файла»)."""
def file_out(
file: StoredFile,
job: RecognitionJob | None,
documents: Sequence[Document],
counts: Mapping[uuid.UUID, int] | None = None,
) -> FileOut:
"""Карточка файла (spec documents-api «Файлы организации и карточка файла»).
`counts` — `document_id → число комментариев` (`files_repo.comments_count_by_document`); нет — нули.
"""
counts = counts or {}
return FileOut(
id=file.id,
legal_entity_id=file.legal_entity_id,
@ -82,7 +94,10 @@ def file_out(file: StoredFile, job: RecognitionJob | None, documents: Sequence[D
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],
documents=[
DocumentBrief.model_validate(document).model_copy(update={"comments_count": counts.get(document.id, 0)})
for document in documents
],
)
@ -97,8 +112,9 @@ def _group_schema(job: RecognitionJob | None, locator: str | None) -> list[Group
return None
def document_out(document: Document, job: RecognitionJob | None) -> DocumentOut:
"""Карточка документа; `job` — текущее задание его файла (схема группировок и предупреждения маппинга)."""
def document_out(document: Document, job: RecognitionJob | None, comments_count: int = 0) -> DocumentOut:
"""Карточка документа; `job` — текущее задание его файла (схема группировок и предупреждения маппинга),
`comments_count` — число комментариев документа."""
return DocumentOut(
id=document.id,
file_id=document.stored_file_id,
@ -117,6 +133,16 @@ def document_out(document: Document, job: RecognitionJob | None) -> DocumentOut:
group_schema=_group_schema(job, document.locator),
# ASSUMPTION(R-6): в DBML нет привязки предупреждения к документу — отдаются предупреждения маппинга файла
warnings=_strings(_metrics(job).get(MAPPING_WARNINGS_KEY)),
comments_count=comments_count,
)
def _comment_out(comment: Comment, full_name: str) -> CommentOut:
return CommentOut(
id=comment.id,
text=comment.text,
author=CommentAuthorOut(id=comment.author_id, full_name=full_name),
created_at=comment.created_at,
)
@ -139,11 +165,38 @@ class DocumentService:
return None
return await self._session.get(RecognitionJob, file.current_recognition_job_id)
async def _comments_count(self, document_id: uuid.UUID) -> int:
counts = await files_repo.comments_count_by_document(self._session, [document_id])
return counts.get(document_id, 0)
async def comments(self, document_id: uuid.UUID) -> list[CommentOut]:
"""Комментарии видимого документа по `created_at` с ФИО автора; иначе 404."""
document = await self._visible(document_id)
return [
_comment_out(comment, full_name)
for comment, full_name in await files_repo.list_comments(self._session, document.id)
]
async def add_comment(self, document_id: uuid.UUID, body: CommentIn) -> CommentOut:
"""Комментарий к видимому документу в любом статусе, в том числе файла `SUPERSEDED`; аудит не пишется (D-11)."""
document = await self._visible(document_id)
comment = Comment(
object_type=files_repo.COMMENT_OBJECT_TYPE,
object_id=document.id,
text=body.text,
author_id=self._audit.user_id,
created_at=utc_now(),
)
self._session.add(comment)
await self._session.flush()
author = await self._session.get(AppUser, comment.author_id)
return _comment_out(comment, author.full_name if author is not None else "")
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))
return document_out(document, await self._current_job(file), await self._comments_count(document.id))
async def entries(self, document_id: uuid.UUID, page: EntriesPageParams) -> tuple[list[OsvEntry], int]:
"""Страница строк видимого документа по `row_number`; иначе 404."""
@ -230,4 +283,4 @@ class DocumentService:
before_value={"status": before.value},
after_value={"status": DocumentStatus.CONFIRMED.value},
)
return document_out(document, await self._current_job(file))
return document_out(document, await self._current_job(file), await self._comments_count(document.id))

View File

@ -262,7 +262,8 @@ class PrimaryFilesService:
"файл нельзя удалить: он заменил более ранние версии, цепочка версий сохраняется",
)
current = next((job for job in jobs if job.id == file.current_recognition_job_id), None)
before = file_out(file, current, documents).model_dump(mode="json")
counts = await files_repo.comments_count_by_document(self._session, [document.id for document in documents])
before = file_out(file, current, documents, counts).model_dump(mode="json")
object_ids = [file.s3_object_id, *(job.result_s3_object_id for job in jobs if job.result_s3_object_id)]
await files_repo.delete_file_rows(self._session, file)
# M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла
@ -282,7 +283,10 @@ class PrimaryFilesService:
async def _cards(self, files: list[StoredFile]) -> list[FileOut]:
jobs = await files_repo.current_jobs(self._session, files)
documents = await files_repo.documents_by_file(self._session, [file.id for file in files])
return [file_out(file, jobs.get(file.id), documents.get(file.id, [])) for file in files]
counts = await files_repo.comments_count_by_document(
self._session, [document.id for group in documents.values() for document in group]
)
return [file_out(file, jobs.get(file.id), documents.get(file.id, []), counts) for file in files]
async def find_duplicate(self, entity_id: uuid.UUID, sha256: str) -> StoredFile | None:
"""Файл ЮЛ с тем же содержимым, кроме заменённых (`SUPERSEDED`): дубль по spec «Дедуп по содержимому»."""

View File

@ -0,0 +1,67 @@
"""Комментарии к документу (spec documents-api «Комментарии к документу», «Файлы организации…»; D-11)."""
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.enums import DocumentStatus, FileStatus, RecognitionStatus
from tests.conftest import AuthClientFactory
from tests.factories import make_document, make_stored_file
from tests.test_api_documents import KA_RF, RKA, _rostov_entity
pytestmark = pytest.mark.integration
HTTP_OK = 200
HTTP_CREATED = 201
HTTP_FORBIDDEN = 403
HTTP_NOT_FOUND = 404
HTTP_UNPROCESSABLE = 422
COMMENTS_TOTAL = 2
async def _doc(session: AsyncSession, status: DocumentStatus = DocumentStatus.CONFIRMED):
entity = await _rostov_entity(session, name="ООО Комментарии")
file, job = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
return file, await make_document(session, file=file, job=job, status=status)
async def test_add_and_list(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
file, document = await _doc(seeded)
client = await auth_client(KA_RF)
created = await client.post(f"/documents/{document.id}/comments", json={"text": " нет подписи руководителя "})
assert created.status_code == HTTP_CREATED, created.text
assert created.json()["text"] == "нет подписи руководителя"
assert created.json()["author"]["full_name"]
await client.post(f"/documents/{document.id}/comments", json={"text": "второй"})
listed = (await client.get(f"/documents/{document.id}/comments")).json()
assert [item["text"] for item in listed] == ["нет подписи руководителя", "второй"]
card = (await client.get(f"/files/{file.id}")).json()
assert card["documents"][0]["comments_count"] == COMMENTS_TOTAL
assert (await client.get(f"/documents/{document.id}")).json()["comments_count"] == COMMENTS_TOTAL
async def test_validation_and_rights(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
_, document = await _doc(seeded)
url = f"/documents/{document.id}/comments"
client = await auth_client(KA_RF)
assert (await client.post(url, json={"text": " "})).status_code == HTTP_UNPROCESSABLE
assert (await client.post(url, json={"text": "x" * 4001})).status_code == HTTP_UNPROCESSABLE
reader = await auth_client(RKA)
assert (await reader.post(url, json={"text": "можно?"})).status_code == HTTP_FORBIDDEN
assert (await reader.get(url)).status_code == HTTP_OK
stranger = await auth_client("client_manager@armka.local")
assert (await stranger.get(url)).status_code == HTTP_NOT_FOUND
async def test_comment_on_superseded_file(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
entity = await _rostov_entity(seeded, name="ООО Заменённый")
file, job = await make_stored_file(
session=seeded, entity=entity, status=FileStatus.SUPERSEDED, job_status=RecognitionStatus.done
)
document = await make_document(seeded, file=file, job=job, status=DocumentStatus.RECOGNIZED)
client = await auth_client(KA_RF)
created = await client.post(f"/documents/{document.id}/comments", json={"text": "архивная версия"})
assert created.status_code == HTTP_CREATED, created.text
listing = (await client.get(f"/legal-entities/{entity.id}/files", params={"include_superseded": "true"})).json()
assert listing["items"][0]["documents"][0]["comments_count"] == 1

View File

@ -139,6 +139,7 @@ async def test_document_card(seeded: AsyncSession, auth_client: AuthClientFactor
],
# R-6: предупреждения маппинга файла целиком, без фильтра по листу
"warnings": ["sheet=Лист_1: повтор", "sheet=Лист_2: сироты"],
"comments_count": 0,
}

View File

@ -142,6 +142,7 @@ async def test_list_processed_with_document_and_error(seeded: AsyncSession, auth
"period_check": None,
"counterparty_check": None,
"locator": "sheet:Лист_1",
"comments_count": 0,
}
]

View File

@ -51,6 +51,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
"/api/v1/documents/{document_id}": {"get"},
"/api/v1/documents/{document_id}/entries": {"get"},
"/api/v1/documents/{document_id}/confirm": {"post"},
"/api/v1/documents/{document_id}/comments": {"get", "post"},
# форма ББ/ОФР (Срез 3b; PATCH и accept добавит Task 6)
"/api/v1/documents/{document_id}/statement": {"get", "patch"},
"/api/v1/documents/{document_id}/statement/checks/accept": {"post"},