"""Чтение файлов организации и карточки файла (spec documents-api «Файлы организации и карточка файла»; design D-11). Сценарии поверх фикстуры `app` (транзакция теста с SAVEPOINT на запрос): файлы, задания и документы создаются фабриками в той же транзакции. Видимость — через ЮЛ-владельца: ЮЛ Ростовского филиала видит `ka_rf`, не видит `client_manager` (Краснодарский филиал); `curator_go` без `DOCUMENT_VIEW`. """ import datetime import uuid from collections.abc import Iterator from contextlib import contextmanager from typing import Any import pytest from sqlalchemy import event, select from sqlalchemy.ext.asyncio import AsyncSession from src.models.directories import LegalEntity, RegionalOffice from src.models.enums import DocumentStatus, FileStatus, PeriodQuarter, RecognitionStatus from src.models.files import RecognitionJob, StoredFile from tests.conftest import AuthClientFactory from tests.factories import make_company_group, make_document, make_legal_entity, make_stored_file HTTP_OK = 200 HTTP_FORBIDDEN = 403 HTTP_NOT_FOUND = 404 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 = "Ростовский региональный филиал" T0 = datetime.datetime(2026, 10, 1, 9, 0, tzinfo=datetime.UTC) pytestmark = pytest.mark.integration async def _rostov_entity(session: AsyncSession, name: str = "ООО Чтение файлов") -> LegalEntity: office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() group = await make_company_group(session, name=f"ГК {name}", office=office) return await make_legal_entity(session, name=name, group=group) async def _file( # noqa: PLR0913 - каждое поле файла задаётся тестом явно session: AsyncSession, entity: LegalEntity, *, minutes: int, status: FileStatus = FileStatus.PROCESSED, job_status: RecognitionStatus = RecognitionStatus.done, metrics: dict[str, Any] | None = None, ) -> tuple[StoredFile, RecognitionJob]: file, job = await make_stored_file( session, entity=entity, status=status, job_status=job_status, original_name=f"osv-{minutes}.xls" ) file.uploaded_at = T0 + datetime.timedelta(minutes=minutes) job.metrics = metrics await session.flush() return file, job def _files_url(entity_id: uuid.UUID) -> str: return f"/legal-entities/{entity_id}/files" @contextmanager def _statements(session: AsyncSession) -> Iterator[list[str]]: """SQL-операторы, выполненные на соединении теста (запросы приложения идут в ту же транзакцию).""" bind: Any = session.bind connection = bind.sync_connection statements: list[str] = [] def before_cursor_execute(*args: Any) -> None: statements.append(args[2]) event.listen(connection, "before_cursor_execute", before_cursor_execute) try: yield statements finally: event.remove(connection, "before_cursor_execute", before_cursor_execute) # --- «Список после обработки» --------------------------------------------------------------------------------------- async def test_list_processed_with_document_and_error(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: entity = await _rostov_entity(seeded) processed, processed_job = await _file( seeded, entity, minutes=1, metrics={"warnings": ["sheet=Лист_1 hierarchy=outline"], "mapping_warnings": ["sheet=Лист_1: сироты"]}, ) processed_job.finished_at = T0 document = await make_document( seeded, file=processed, job=processed_job, account="66", year=2024, quarter=PeriodQuarter.Q4 ) document.locator = "sheet:Лист_1" failed, failed_job = await _file( seeded, entity, minutes=2, status=FileStatus.ERROR, job_status=RecognitionStatus.error ) failed_job.error_code = "PARSE_ERROR" failed_job.error_message = "лист не распознан" await seeded.flush() client = await auth_client(KA_RF) response = await client.get(_files_url(entity.id)) assert response.status_code == HTTP_OK, response.text body = response.json() assert (body["total"], body["limit"], body["offset"]) == (2, 50, 0) newest, oldest = body["items"] # по убыванию uploaded_at assert (newest["id"], oldest["id"]) == (str(failed.id), str(processed.id)) assert newest["status"] == "ERROR" assert newest["documents"] == [] assert newest["job"]["error_code"] == "PARSE_ERROR" assert newest["job"]["error_message"] == "лист не распознан" assert newest["job"]["status"] == "error" assert oldest["status"] == "PROCESSED" assert oldest["legal_entity_id"] == str(entity.id) assert oldest["original_name"] == "osv-1.xls" assert oldest["job"] == { "id": str(processed_job.id), "status": "done", "error_code": None, "error_message": None, "mapping_error": None, "warnings": ["sheet=Лист_1 hierarchy=outline"], "mapping_warnings": ["sheet=Лист_1: сироты"], "finished_at": "2026-10-01T09:00:00Z", } assert oldest["documents"] == [ { "id": str(document.id), "document_type": "OSV", "osv_account_code": "66", "period_year": 2024, "period_quarter": "Q4", "is_cumulative": True, "status": "RECOGNIZED", "period_check": None, "counterparty_check": None, "locator": "sheet:Лист_1", } ] async def test_list_hides_superseded_by_default(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: entity = await _rostov_entity(seeded) new, _ = await _file(seeded, entity, minutes=2) old, _ = await _file(seeded, entity, minutes=1, status=FileStatus.SUPERSEDED) old.superseded_by_file_id = new.id await seeded.flush() client = await auth_client(KA_RF) default = (await client.get(_files_url(entity.id))).json() full = (await client.get(_files_url(entity.id), params={"include_superseded": "true"})).json() assert [item["id"] for item in default["items"]] == [str(new.id)] assert default["total"] == 1 assert [item["id"] for item in full["items"]] == [str(new.id), str(old.id)] assert full["items"][1]["superseded_by_file_id"] == str(new.id) assert full["total"] == 2 # noqa: PLR2004 async def test_list_pagination(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: entity = await _rostov_entity(seeded) files = [(await _file(seeded, entity, minutes=minute))[0] for minute in range(3)] client = await auth_client(KA_RF) body = (await client.get(_files_url(entity.id), params={"limit": 1, "offset": 1})).json() assert (body["total"], body["limit"], body["offset"]) == (3, 1, 1) assert [item["id"] for item in body["items"]] == [str(files[1].id)] async def test_list_has_no_n_plus_one(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """Число SQL-запросов списка не зависит от числа файлов и документов на странице.""" client = await auth_client(KA_RF) assert (await client.get("/me")).status_code == HTTP_OK # первый запрос пишет LOGIN — вне сравнения one = await _rostov_entity(seeded, "ООО Один файл") file, job = await _file(seeded, one, minutes=1) await make_document(seeded, file=file, job=job) many = await _rostov_entity(seeded, "ООО Много файлов") for minute in range(4): file, job = await _file(seeded, many, minutes=minute) await make_document(seeded, file=file, job=job, account="60") await make_document(seeded, file=file, job=job, account="62") with _statements(seeded) as single: assert (await client.get(_files_url(one.id))).status_code == HTTP_OK with _statements(seeded) as multiple: response = await client.get(_files_url(many.id)) assert response.status_code == HTTP_OK assert len(response.json()["items"]) == 4 # noqa: PLR2004 assert len(multiple) == len(single), [statement[:80] for statement in multiple] # --- карточка файла -------------------------------------------------------------------------------------------------- async def test_file_card_equals_list_item(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: entity = await _rostov_entity(seeded) file, job = await _file( seeded, entity, minutes=1, status=FileStatus.ERROR, metrics={"mapping_error": "в результате нет документов ОСВ"} ) client = await auth_client(KA_RF) card = await client.get(f"/files/{file.id}") listed = (await client.get(_files_url(entity.id))).json()["items"][0] assert card.status_code == HTTP_OK, card.text assert card.json() == listed assert card.json()["job"]["id"] == str(job.id) assert card.json()["job"]["mapping_error"] == "в результате нет документов ОСВ" assert card.json()["job"]["warnings"] == [] assert card.json()["job"]["mapping_warnings"] == [] async def test_file_card_without_current_job(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: entity = await _rostov_entity(seeded) file, _ = await _file(seeded, entity, minutes=1, status=FileStatus.UPLOADED) file.current_recognition_job_id = None await seeded.flush() client = await auth_client(KA_RF) body = (await client.get(f"/files/{file.id}")).json() assert body["job"] is None assert body["documents"] == [] # --- видимость и права ----------------------------------------------------------------------------------------------- async def test_foreign_and_missing_file_are_same_404(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: entity = await _rostov_entity(seeded) file, _ = await _file(seeded, entity, minutes=1) client = await auth_client(CLIENT_MANAGER) # Краснодарский филиал foreign = await client.get(f"/files/{file.id}") missing = await client.get(f"/files/{uuid.uuid4()}") 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_files_of_foreign_entity_are_404(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: entity = await _rostov_entity(seeded) await _file(seeded, entity, minutes=1) client = await auth_client(CLIENT_MANAGER) foreign = await client.get(_files_url(entity.id)) missing = await client.get(_files_url(uuid.uuid4())) assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND assert foreign.json() == missing.json() async def test_reading_files_requires_document_view(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: entity = await _rostov_entity(seeded) file, _ = await _file(seeded, entity, minutes=1) client = await auth_client(CURATOR_GO) # ГО видит всё, но права DOCUMENT_VIEW нет assert (await client.get(_files_url(entity.id))).status_code == HTTP_FORBIDDEN assert (await client.get(f"/files/{file.id}")).status_code == HTTP_FORBIDDEN async def test_unconfirmed_document_status_is_listed(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """Статус документа в карточке файла — как в БД (`NEEDS_CHECK` виден аналитику до подтверждения).""" entity = await _rostov_entity(seeded) file, job = await _file(seeded, entity, minutes=1) await make_document(seeded, file=file, job=job, status=DocumentStatus.NEEDS_CHECK) client = await auth_client(KA_RF) body = (await client.get(f"/files/{file.id}")).json() assert [document["status"] for document in body["documents"]] == ["NEEDS_CHECK"]