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 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 = APIRouter(tags=["files"])
router.include_router(upload.router) router.include_router(upload.router)
router.include_router(read.router) router.include_router(read.router)
router.include_router(documents.router) router.include_router(documents.router)
router.include_router(comments.router)
router.include_router(statements.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 sqlalchemy.ext.asyncio import AsyncSession
from src.domain.policy import VisibilityScope, legal_entities_visible 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.directories import LegalEntity
from src.models.enums import FileStatus, S3BucketRole from src.models.enums import FileStatus, S3BucketRole
from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.files import Document, RecognitionJob, S3Object, StoredFile
from src.models.osv import OsvEntry from src.models.osv import OsvEntry
COMMENT_OBJECT_TYPE = "document" # `comment.object_type` комментариев документа
def _visible_files(stmt: Select[Any], scope: VisibilityScope) -> Select[Any]: def _visible_files(stmt: Select[Any], scope: VisibilityScope) -> Select[Any]:
"""Файлы, чьё ЮЛ-владелец в области видимости (`stmt` ссылается на `StoredFile`).""" """Файлы, чьё ЮЛ-владелец в области видимости (`stmt` ссылается на `StoredFile`)."""
@ -91,6 +95,29 @@ async def documents_by_file(session: AsyncSession, file_ids: Sequence[uuid.UUID]
return grouped 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: async def get_document(session: AsyncSession, scope: VisibilityScope, document_id: uuid.UUID) -> Document | None:
"""Документ по id; `None` и для несуществующего, и для документа ЮЛ вне видимости.""" """Документ по id; `None` и для несуществующего, и для документа ЮЛ вне видимости."""
stmt = _visible_documents(select(Document).where(Document.id == document_id), scope) 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 confirmed_at: datetime.datetime | None
group_schema: list[GroupOut] | None group_schema: list[GroupOut] | None
warnings: list[str] warnings: list[str]
comments_count: int = 0 # число комментариев документа (D-11)
class EntryOut(BaseModel): class EntryOut(BaseModel):

View File

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

View File

@ -11,7 +11,7 @@
""" """
import uuid import uuid
from collections.abc import Sequence from collections.abc import Mapping, Sequence
from typing import Any from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession 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.clock import utc_now
from src.common.exceptions import ConflictError, NotFoundError from src.common.exceptions import ConflictError, NotFoundError
from src.domain.policy import VisibilityScope 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.enums import AuditAction, AuditSection, DocumentStatus, FileStatus
from src.models.files import Document, RecognitionJob, StoredFile from src.models.files import Document, RecognitionJob, StoredFile
from src.models.osv import OsvEntry from src.models.osv import OsvEntry
from src.models.statements import FinancialStatementMeta from src.models.statements import FinancialStatementMeta
from src.repositories import files as files_repo from src.repositories import files as files_repo
from src.repositories import statements as statements_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.documents import DocumentOut, EntriesPageParams, GroupOut
from src.schemas.files import DocumentBrief, FileOut, JobBrief from src.schemas.files import DocumentBrief, FileOut, JobBrief
from src.services import consolidation_documents 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: def file_out(
"""Карточка файла (spec documents-api «Файлы организации и карточка файла»).""" 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( return FileOut(
id=file.id, id=file.id,
legal_entity_id=file.legal_entity_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, uploaded_by=file.uploaded_by,
superseded_by_file_id=file.superseded_by_file_id, superseded_by_file_id=file.superseded_by_file_id,
job=job_brief(job) if job is not None else None, 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 return None
def document_out(document: Document, job: RecognitionJob | None) -> DocumentOut: def document_out(document: Document, job: RecognitionJob | None, comments_count: int = 0) -> DocumentOut:
"""Карточка документа; `job` — текущее задание его файла (схема группировок и предупреждения маппинга).""" """Карточка документа; `job` — текущее задание его файла (схема группировок и предупреждения маппинга),
`comments_count` — число комментариев документа."""
return DocumentOut( return DocumentOut(
id=document.id, id=document.id,
file_id=document.stored_file_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), group_schema=_group_schema(job, document.locator),
# ASSUMPTION(R-6): в DBML нет привязки предупреждения к документу — отдаются предупреждения маппинга файла # ASSUMPTION(R-6): в DBML нет привязки предупреждения к документу — отдаются предупреждения маппинга файла
warnings=_strings(_metrics(job).get(MAPPING_WARNINGS_KEY)), 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 None
return await self._session.get(RecognitionJob, file.current_recognition_job_id) 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: async def get(self, document_id: uuid.UUID) -> DocumentOut:
"""Карточка видимого документа; иначе 404.""" """Карточка видимого документа; иначе 404."""
document = await self._visible(document_id) document = await self._visible(document_id)
file = await self._session.get(StoredFile, document.stored_file_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]: async def entries(self, document_id: uuid.UUID, page: EntriesPageParams) -> tuple[list[OsvEntry], int]:
"""Страница строк видимого документа по `row_number`; иначе 404.""" """Страница строк видимого документа по `row_number`; иначе 404."""
@ -230,4 +283,4 @@ class DocumentService:
before_value={"status": before.value}, before_value={"status": before.value},
after_value={"status": DocumentStatus.CONFIRMED.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) 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)] 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) await files_repo.delete_file_rows(self._session, file)
# M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла # M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла
@ -282,7 +283,10 @@ class PrimaryFilesService:
async def _cards(self, files: list[StoredFile]) -> list[FileOut]: async def _cards(self, files: list[StoredFile]) -> list[FileOut]:
jobs = await files_repo.current_jobs(self._session, files) jobs = await files_repo.current_jobs(self._session, files)
documents = await files_repo.documents_by_file(self._session, [file.id for file in 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: async def find_duplicate(self, entity_id: uuid.UUID, sha256: str) -> StoredFile | None:
"""Файл ЮЛ с тем же содержимым, кроме заменённых (`SUPERSEDED`): дубль по spec «Дедуп по содержимому».""" """Файл ЮЛ с тем же содержимым, кроме заменённых (`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: предупреждения маппинга файла целиком, без фильтра по листу # R-6: предупреждения маппинга файла целиком, без фильтра по листу
"warnings": ["sheet=Лист_1: повтор", "sheet=Лист_2: сироты"], "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, "period_check": None,
"counterparty_check": None, "counterparty_check": None,
"locator": "sheet:Лист_1", "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}": {"get"},
"/api/v1/documents/{document_id}/entries": {"get"}, "/api/v1/documents/{document_id}/entries": {"get"},
"/api/v1/documents/{document_id}/confirm": {"post"}, "/api/v1/documents/{document_id}/confirm": {"post"},
"/api/v1/documents/{document_id}/comments": {"get", "post"},
# форма ББ/ОФР (Срез 3b; PATCH и accept добавит Task 6) # форма ББ/ОФР (Срез 3b; PATCH и accept добавит Task 6)
"/api/v1/documents/{document_id}/statement": {"get", "patch"}, "/api/v1/documents/{document_id}/statement": {"get", "patch"},
"/api/v1/documents/{document_id}/statement/checks/accept": {"post"}, "/api/v1/documents/{document_id}/statement/checks/accept": {"post"},