api: рабочий стол консолидации для ЮЛ и consolidation_id в карточке файла (#96)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-10 01:10:21 +04:00
parent d5d09d0b5c
commit 956a730a3c
9 changed files with 316 additions and 3 deletions

View File

@ -2,11 +2,12 @@
from fastapi import APIRouter from fastapi import APIRouter
from src.api.endpoints.files import comments, documents, read, statements, upload from src.api.endpoints.files import comments, consolidation_files, 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(consolidation_files.router)
router.include_router(documents.router) router.include_router(documents.router)
router.include_router(comments.router) router.include_router(comments.router)
router.include_router(statements.router) router.include_router(statements.router)

View File

@ -0,0 +1,46 @@
"""Файлы ЮЛ в контексте консолидации (v5.18, RSHB/arm-ka-docs#90): `GET /consolidations/{consolidation_id}/
legal-entities/{legal_entity_id}/files` — рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол
консолидации для ЮЛ»);
`POST …/files` — загрузка в консолидацию (spec primary-files «Загрузка файлов в консолидацию»). Права — как у маршрутов
ЮЛ (`DOCUMENT_VIEW` / `DOCUMENT_EDIT`, design R-1); консолидация или ЮЛ вне видимости — 404, как несуществующие."""
import uuid
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Query, status
from src.api.dependencies.auth import require
from src.api.dependencies.files import PrimaryFilesServiceDep
from src.api.endpoints.files.read import FileListQuery
from src.schemas.common import ErrorDetail, Page, PageParams
from src.schemas.files import FileOut
router = APIRouter()
VIEW = [Depends(require("DOCUMENT_VIEW"))]
EDIT = [Depends(require("DOCUMENT_EDIT"))]
NOT_FOUND: dict[int | str, dict[str, Any]] = {
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "консолидация или ЮЛ: нет или вне видимости"}
}
DESK_PATH = "/consolidations/{consolidation_id}/legal-entities/{legal_entity_id}/files"
@router.get(
DESK_PATH,
response_model=Page[FileOut],
dependencies=VIEW,
responses=NOT_FOUND,
summary="Рабочий стол консолидации для ЮЛ",
)
async def list_desk_files(
consolidation_id: uuid.UUID,
legal_entity_id: uuid.UUID,
service: PrimaryFilesServiceDep,
query: Annotated[FileListQuery, Query()],
) -> Page[FileOut]:
"""Файлы ЮЛ, загруженные в эту консолидацию, плюс файлы с документами, привязанными к её версиям (новые первыми)."""
page = PageParams(limit=query.limit, offset=query.offset)
items, total = await service.page_desk(
consolidation_id, legal_entity_id, include_superseded=query.include_superseded, page=page
)
return Page[FileOut](items=items, total=total, limit=page.limit, offset=page.offset)

View File

@ -71,6 +71,14 @@ async def get_version(
return None if row is None else (row[0], row[1]) return None if row is None else (row[0], row[1])
async def get_consolidation(
session: AsyncSession, scope: VisibilityScope, consolidation_id: uuid.UUID
) -> Consolidation | None:
"""Консолидация по id, если её ГК видна пользователю; чужая и несуществующая — `None` (v5.18, design D-3)."""
stmt = _visible_consolidations(scope).where(Consolidation.id == consolidation_id)
return (await session.execute(stmt)).scalar_one_or_none()
async def get_consolidation_for_group( async def get_consolidation_for_group(
session: AsyncSession, scope: VisibilityScope, group_id: uuid.UUID, year: int, quarter: PeriodQuarter session: AsyncSession, scope: VisibilityScope, group_id: uuid.UUID, year: int, quarter: PeriodQuarter
) -> Consolidation | None: ) -> Consolidation | None:

View File

@ -12,12 +12,13 @@ import uuid
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any from typing import Any
from sqlalchemy import Select, and_, delete, func, or_, select, update from sqlalchemy import Select, and_, delete, exists, 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.access import AppUser
from src.models.audit_ex import Comment from src.models.audit_ex import Comment
from src.models.consolidation import ConsolidationDocument, ConsolidationVersion
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
@ -237,3 +238,43 @@ async def expire_s3_objects(
) )
) )
await session.execute(update(S3Object).where(S3Object.id.in_(object_ids), owned).values(expires_at=now)) await session.execute(update(S3Object).where(S3Object.id.in_(object_ids), owned).values(expires_at=now))
async def list_desk_files( # noqa: PLR0913 - фильтр и пагинация списка передаются явными именованными аргументами
session: AsyncSession,
scope: VisibilityScope,
entity_id: uuid.UUID,
consolidation_id: uuid.UUID,
*,
include_superseded: bool,
limit: int,
offset: int,
) -> tuple[list[StoredFile], int]:
"""Рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»; §9.5 п. 11; D-6):
файлы ЮЛ с `consolidation_id` этой консолидации ИЛИ с документом, привязанным к любой её версии. Порядок и
`include_superseded` — как в `list_files`; страница и общее число тем же фильтром."""
bound = exists().where(
Document.stored_file_id == StoredFile.id,
ConsolidationDocument.document_id == Document.id,
ConsolidationVersion.id == ConsolidationDocument.consolidation_version_id,
ConsolidationVersion.consolidation_id == consolidation_id,
)
def filtered(stmt: Select[Any]) -> Select[Any]:
stmt = _visible_files(stmt, scope).where(
StoredFile.legal_entity_id == entity_id,
or_(StoredFile.consolidation_id == consolidation_id, bound),
)
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

View File

@ -93,5 +93,6 @@ class FileOut(BaseModel):
uploaded_at: datetime.datetime | None uploaded_at: datetime.datetime | None
uploaded_by: uuid.UUID | None uploaded_by: uuid.UUID | None
superseded_by_file_id: uuid.UUID | None superseded_by_file_id: uuid.UUID | None
consolidation_id: uuid.UUID | None # v5.18: консолидация, в контексте которой загружен файл; None — без контекста
job: JobBrief | None job: JobBrief | None
documents: list[DocumentBrief] documents: list[DocumentBrief]

View File

@ -18,6 +18,7 @@ from src.models.consolidation import Consolidation, ConsolidationVersion
from src.repositories import consolidations as repo from src.repositories import consolidations as repo
VERSION_NOT_FOUND = "версия консолидации не найдена" # одинаково для чужой и несуществующей VERSION_NOT_FOUND = "версия консолидации не найдена" # одинаково для чужой и несуществующей
CONSOLIDATION_NOT_FOUND = "консолидация не найдена" # одинаково для чужой и несуществующей
async def lock_visible_version( async def lock_visible_version(

View File

@ -93,6 +93,7 @@ def file_out(
uploaded_at=file.uploaded_at, uploaded_at=file.uploaded_at,
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,
consolidation_id=file.consolidation_id,
job=job_brief(job) if job is not None else None, job=job_brief(job) if job is not None else None,
documents=[ documents=[
DocumentBrief.model_validate(document).model_copy(update={"comments_count": counts.get(document.id, 0)}) DocumentBrief.model_validate(document).model_copy(update={"comments_count": counts.get(document.id, 0)})

View File

@ -56,7 +56,7 @@ from src.schemas.common import PageParams
from src.schemas.files import FileOut from src.schemas.files import FileOut
from src.services import consolidation_documents from src.services import consolidation_documents
from src.services.audit import AuditService from src.services.audit import AuditService
from src.services.consolidation_guard import VERSION_NOT_FOUND from src.services.consolidation_guard import CONSOLIDATION_NOT_FOUND, VERSION_NOT_FOUND
from src.services.documents import file_out from src.services.documents import file_out
from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND
from src.services.result_mapping import RESULTS_PREFIX from src.services.result_mapping import RESULTS_PREFIX
@ -241,6 +241,26 @@ class PrimaryFilesService:
) )
return await self._cards(files), total return await self._cards(files), total
async def page_desk(
self, consolidation_id: uuid.UUID, entity_id: uuid.UUID, *, include_superseded: bool, page: PageParams
) -> tuple[list[FileOut], int]:
"""Рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»; §9.5 п. 11; D-6):
консолидация вне видимости — 404 `CONSOLIDATION_NOT_FOUND`, ЮЛ вне видимости — 404; принадлежность ЮЛ к ГК
консолидации не проверяется (чужому ЮЛ — пустой стол). Карточки — как в `page_files`."""
if await consolidations_repo.get_consolidation(self._session, self._scope, consolidation_id) is None:
raise NotFoundError(CONSOLIDATION_NOT_FOUND)
entity = await self.get_entity(entity_id)
files, total = await files_repo.list_desk_files(
self._session,
self._scope,
entity.id,
consolidation_id,
include_superseded=include_superseded,
limit=page.limit,
offset=page.offset,
)
return await self._cards(files), total
async def get_file(self, file_id: uuid.UUID) -> FileOut: async def get_file(self, file_id: uuid.UUID) -> FileOut:
"""Карточка видимого файла; чужой и несуществующий — одинаковый 404.""" """Карточка видимого файла; чужой и несуществующий — одинаковый 404."""
file = await files_repo.get_file(self._session, self._scope, file_id) file = await files_repo.get_file(self._session, self._scope, file_id)

View File

@ -0,0 +1,194 @@
"""Рабочий стол консолидации для ЮЛ и `consolidation_id` в карточке файла (spec documents-api «Рабочий стол консолидации
для ЮЛ», «Файлы организации и карточка файла»; design D-6, D-7). Поверх фикстуры `app`: данные — фабрики в транзакции
теста."""
import uuid
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.access import AppUser
from src.models.consolidation import ConsolidationDocument
from src.models.directories import LegalEntity, RegionalOffice
from src.models.enums import DocumentStatus, FileStatus, PeriodQuarter, RecognitionStatus
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file
HTTP_OK = 200
HTTP_NOT_FOUND = 404
HTTP_FORBIDDEN = 403
KA_RF = "ka_rf@armka.local" # Ростовский филиал, DOCUMENT_VIEW + DOCUMENT_EDIT
CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал
CURATOR_GO = "curator_go@armka.local" # головной офис, права DOCUMENT_VIEW нет
ROSTOV = "Ростовский региональный филиал"
KRASNODAR = "Краснодарский региональный филиал"
pytestmark = pytest.mark.integration
async def _group(session: AsyncSession, office_name: str = ROSTOV, name: str = "ГК Стол"):
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == office_name))).scalar_one()
group = await make_company_group(session, name=name, office=office)
entity = await make_legal_entity(session, name=f"ООО {name}", group=group)
initiator = (await session.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
return group, entity, initiator
async def _consolidation( # noqa: PLR0913, PLR0917 - тестовый помощник
session: AsyncSession, group, entity, initiator, year: int, quarter: PeriodQuarter
):
consolidation, version = await make_consolidation(
session, group=group, year=year, quarter=quarter, members=[entity], initiator=initiator
)
return consolidation, version
async def _file(
session: AsyncSession, entity: LegalEntity, name: str, *, consolidation_id=None, status=FileStatus.PROCESSED
):
file, job = await make_stored_file(
session, entity=entity, status=status, job_status=RecognitionStatus.done, original_name=name
)
file.consolidation_id = consolidation_id
await session.flush()
return file, job
def _desk_url(consolidation_id: uuid.UUID, entity_id: uuid.UUID, **query: str) -> str:
base = f"/consolidations/{consolidation_id}/legal-entities/{entity_id}/files"
return base + ("?" + "&".join(f"{k}={v}" for k, v in query.items()) if query else "")
async def test_desk_shows_only_files_of_consolidation(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""Сценарий «Стол показывает только файлы консоли»: три файла ЮЛ — 2025 Q1, 2025 Q2, без контекста."""
group, entity, initiator = await _group(seeded)
c1, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1)
c2, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q2)
f1, _ = await _file(seeded, entity, "q1.xls", consolidation_id=c1.id)
f2, _ = await _file(seeded, entity, "q2.xls", consolidation_id=c2.id)
f0, _ = await _file(seeded, entity, "none.xls")
client = await auth_client(KA_RF)
desk1 = await client.get(_desk_url(c1.id, entity.id))
desk2 = await client.get(_desk_url(c2.id, entity.id))
all_files = await client.get(f"/legal-entities/{entity.id}/files")
assert desk1.status_code == HTTP_OK, desk1.text
assert [item["id"] for item in desk1.json()["items"]] == [str(f1.id)]
assert desk1.json()["total"] == 1
assert desk1.json()["items"][0]["consolidation_id"] == str(c1.id)
assert [item["id"] for item in desk2.json()["items"]] == [str(f2.id)]
assert {item["id"] for item in all_files.json()["items"]} == {str(f1.id), str(f2.id), str(f0.id)}
by_id = {item["id"]: item["consolidation_id"] for item in all_files.json()["items"]}
assert by_id[str(f0.id)] is None
async def test_desk_includes_file_bound_to_version(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""Сценарий «Подхваченный файл виден через привязку»: файл без контекста, документ привязан к версии консоли."""
group, entity, initiator = await _group(seeded)
c2, v2 = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q2)
file, job = await _file(seeded, entity, "q1-confirmed.xls")
document = await make_document(
seeded,
file=file,
job=job,
account="60",
year=2025,
quarter=PeriodQuarter.Q1,
status=DocumentStatus.CONFIRMED,
is_cumulative=False,
)
seeded.add(ConsolidationDocument(consolidation_version_id=v2.id, document_id=document.id))
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.get(_desk_url(c2.id, entity.id))
assert response.status_code == HTTP_OK, response.text
assert [item["id"] for item in response.json()["items"]] == [str(file.id)]
assert response.json()["items"][0]["consolidation_id"] is None
async def test_desk_excludes_other_consolidation(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""Review Focus 2: документ привязан к версии ДРУГОЙ консолидации — файл в этом столе не показывается."""
group, entity, initiator = await _group(seeded)
c1, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1)
c2, v2 = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q2)
file, job = await _file(seeded, entity, "bound-elsewhere.xls", consolidation_id=c2.id)
document = await make_document(seeded, file=file, job=job, status=DocumentStatus.CONFIRMED)
seeded.add(ConsolidationDocument(consolidation_version_id=v2.id, document_id=document.id))
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.get(_desk_url(c1.id, entity.id))
assert response.status_code == HTTP_OK, response.text
assert response.json()["items"] == []
assert response.json()["total"] == 0
async def test_desk_superseded_hidden_by_default(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""Сценарий «Заменённый файл скрыт по умолчанию» для DESK и BOUND; `include_superseded=true` — оба."""
group, entity, initiator = await _group(seeded)
c1, v1 = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1)
new, _ = await _file(seeded, entity, "new.xls", consolidation_id=c1.id)
old_desk, _ = await _file(seeded, entity, "old-desk.xls", consolidation_id=c1.id, status=FileStatus.SUPERSEDED)
old_desk.superseded_by_file_id = new.id
old_bound, job = await _file(seeded, entity, "old-bound.xls", status=FileStatus.SUPERSEDED)
old_bound.superseded_by_file_id = new.id
document = await make_document(seeded, file=old_bound, job=job, status=DocumentStatus.CONFIRMED)
seeded.add(ConsolidationDocument(consolidation_version_id=v1.id, document_id=document.id))
await seeded.flush()
client = await auth_client(KA_RF)
default = await client.get(_desk_url(c1.id, entity.id))
with_superseded = await client.get(_desk_url(c1.id, entity.id, include_superseded="true"))
assert [item["id"] for item in default.json()["items"]] == [str(new.id)]
assert {item["id"] for item in with_superseded.json()["items"]} == {
str(new.id),
str(old_desk.id),
str(old_bound.id),
}
assert with_superseded.json()["total"] == 3 # noqa: PLR2004
async def test_desk_foreign_and_missing_consolidation_are_same_404(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""Сценарий «Чужая консолидация»: ГК другого филиала и несуществующий id — одинаковый 404; чужое ЮЛ — тоже 404."""
group, entity, initiator = await _group(seeded, office_name=KRASNODAR, name="ГК Чужая")
c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1)
_, own_entity, _ = await _group(seeded, name="ГК Своя")
client = await auth_client(KA_RF)
foreign = await client.get(_desk_url(c.id, own_entity.id))
missing = await client.get(_desk_url(uuid.uuid4(), own_entity.id))
assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND
assert foreign.json() == missing.json()
assert foreign.json()["detail"]["code"] == "NOT_FOUND"
async def test_desk_requires_document_view(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
group, entity, initiator = await _group(seeded)
c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1)
client = await auth_client(CURATOR_GO)
response = await client.get(_desk_url(c.id, entity.id))
assert response.status_code == HTTP_FORBIDDEN
async def test_file_card_has_consolidation_id(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""Сценарий «Консолидация в карточке»: `GET /files/{id}` отдаёт `consolidation_id`."""
group, entity, initiator = await _group(seeded)
c, _ = await _consolidation(seeded, group, entity, initiator, 2025, PeriodQuarter.Q1)
file, _ = await _file(seeded, entity, "card.xls", consolidation_id=c.id)
client = await auth_client(KA_RF)
response = await client.get(f"/files/{file.id}")
assert response.status_code == HTTP_OK, response.text
assert response.json()["consolidation_id"] == str(c.id)