From 956a730a3c5557bb914b388164b1ec2719a8bfde Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 10 Oct 2026 01:10:21 +0400 Subject: [PATCH] =?UTF-8?q?api:=20=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D0=B9=20=D1=81=D1=82=D0=BE=D0=BB=20=D0=BA=D0=BE=D0=BD=D1=81?= =?UTF-8?q?=D0=BE=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=AE=D0=9B=20=D0=B8=20consolidation=5Fid=20=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D1=84=D0=B0?= =?UTF-8?q?=D0=B9=D0=BB=D0=B0=20(#96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/src/api/endpoints/files/__init__.py | 3 +- .../endpoints/files/consolidation_files.py | 46 +++++ api/src/repositories/consolidations.py | 8 + api/src/repositories/files.py | 43 +++- api/src/schemas/files.py | 1 + api/src/services/consolidation_guard.py | 1 + api/src/services/documents.py | 1 + api/src/services/primary_files.py | 22 +- api/tests/test_api_consolidation_files.py | 194 ++++++++++++++++++ 9 files changed, 316 insertions(+), 3 deletions(-) create mode 100644 api/src/api/endpoints/files/consolidation_files.py create mode 100644 api/tests/test_api_consolidation_files.py diff --git a/api/src/api/endpoints/files/__init__.py b/api/src/api/endpoints/files/__init__.py index 711ad6a..1e2a4d0 100644 --- a/api/src/api/endpoints/files/__init__.py +++ b/api/src/api/endpoints/files/__init__.py @@ -2,11 +2,12 @@ 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.include_router(upload.router) router.include_router(read.router) +router.include_router(consolidation_files.router) router.include_router(documents.router) router.include_router(comments.router) router.include_router(statements.router) diff --git a/api/src/api/endpoints/files/consolidation_files.py b/api/src/api/endpoints/files/consolidation_files.py new file mode 100644 index 0000000..ea3a50e --- /dev/null +++ b/api/src/api/endpoints/files/consolidation_files.py @@ -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) diff --git a/api/src/repositories/consolidations.py b/api/src/repositories/consolidations.py index 4f4801b..f5ca3ab 100644 --- a/api/src/repositories/consolidations.py +++ b/api/src/repositories/consolidations.py @@ -71,6 +71,14 @@ async def get_version( 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( session: AsyncSession, scope: VisibilityScope, group_id: uuid.UUID, year: int, quarter: PeriodQuarter ) -> Consolidation | None: diff --git a/api/src/repositories/files.py b/api/src/repositories/files.py index 98d834e..abed777 100644 --- a/api/src/repositories/files.py +++ b/api/src/repositories/files.py @@ -12,12 +12,13 @@ import uuid from collections.abc import Sequence 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 src.domain.policy import VisibilityScope, legal_entities_visible from src.models.access import AppUser from src.models.audit_ex import Comment +from src.models.consolidation import ConsolidationDocument, ConsolidationVersion from src.models.directories import LegalEntity from src.models.enums import FileStatus, S3BucketRole 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)) + + +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 diff --git a/api/src/schemas/files.py b/api/src/schemas/files.py index 34a5a8e..fe57ba4 100644 --- a/api/src/schemas/files.py +++ b/api/src/schemas/files.py @@ -93,5 +93,6 @@ class FileOut(BaseModel): uploaded_at: datetime.datetime | None uploaded_by: uuid.UUID | None superseded_by_file_id: uuid.UUID | None + consolidation_id: uuid.UUID | None # v5.18: консолидация, в контексте которой загружен файл; None — без контекста job: JobBrief | None documents: list[DocumentBrief] diff --git a/api/src/services/consolidation_guard.py b/api/src/services/consolidation_guard.py index 47cde35..3f6bc93 100644 --- a/api/src/services/consolidation_guard.py +++ b/api/src/services/consolidation_guard.py @@ -18,6 +18,7 @@ from src.models.consolidation import Consolidation, ConsolidationVersion from src.repositories import consolidations as repo VERSION_NOT_FOUND = "версия консолидации не найдена" # одинаково для чужой и несуществующей +CONSOLIDATION_NOT_FOUND = "консолидация не найдена" # одинаково для чужой и несуществующей async def lock_visible_version( diff --git a/api/src/services/documents.py b/api/src/services/documents.py index 393fd60..8a7cf27 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -93,6 +93,7 @@ def file_out( uploaded_at=file.uploaded_at, uploaded_by=file.uploaded_by, 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, documents=[ DocumentBrief.model_validate(document).model_copy(update={"comments_count": counts.get(document.id, 0)}) diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index 7454edf..847010d 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -56,7 +56,7 @@ from src.schemas.common import PageParams from src.schemas.files import FileOut from src.services import consolidation_documents 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.legal_entities import LEGAL_ENTITY_NOT_FOUND from src.services.result_mapping import RESULTS_PREFIX @@ -241,6 +241,26 @@ class PrimaryFilesService: ) 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: """Карточка видимого файла; чужой и несуществующий — одинаковый 404.""" file = await files_repo.get_file(self._session, self._scope, file_id) diff --git a/api/tests/test_api_consolidation_files.py b/api/tests/test_api_consolidation_files.py new file mode 100644 index 0000000..8abe4a4 --- /dev/null +++ b/api/tests/test_api_consolidation_files.py @@ -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)