"""API формы ББ/ОФР (spec documents-api «Форма ББ/ОФР для экрана проверки», «Правка формы», «Акцепт проверок формы», «Подтверждение документа»; statement-checks; design D-5, D-7…D-9, D-14).""" import datetime import json from decimal import Decimal from http import HTTPStatus from pathlib import Path import pytest from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from src.domain.statement_checks import CROSS_FORM_RELATION from src.models.directories import LegalEntity, RegionalOffice from src.models.enums import DocumentType, FileStatus, RecognitionStatus from src.models.files import Document from src.models.statements import ( FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue, ) from tests.conftest import AuthClientFactory from tests.factories import make_company_group, make_document, make_legal_entity, make_stored_file from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot pytestmark = [pytest.mark.integration, pytest.mark.fixtures] KA_RF = "ka_rf@armka.local" RKA = "rka@armka.local" # только DOCUMENT_VIEW ROSTOV = "Ростовский региональный филиал" MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json" MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" MEZH_BALANCE_LINES = 37 async def rostov_entity(session: AsyncSession, path: Path, 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) data = json.loads(path.read_text("utf-8")) inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None) return await make_legal_entity(session, name=name, inn=inn, group=group) async def form(session: AsyncSession, fixtures_dir: Path, path: Path = MEZH_BALANCE) -> Document: entity = await rostov_entity(session, fixtures_dir / path) documents = await map_golden_snapshot(session, entity=entity, path=fixtures_dir / path, uploaded_by=None) return next(d for d in documents if d.document_type in (DocumentType.BALANCE, DocumentType.INCOME)) async def two_forms(session: AsyncSession, fixtures_dir: Path) -> tuple[Document, Document]: """ББ и ОФР Межениновской за 2025 Q3 одного ЮЛ (два файла).""" entity = await rostov_entity(session, fixtures_dir / MEZH_BALANCE) balance = next( d for d in await map_golden_snapshot(session, entity=entity, path=fixtures_dir / MEZH_BALANCE, uploaded_by=None) if d.document_type == DocumentType.BALANCE ) income = next( d for d in await map_golden_snapshot(session, entity=entity, path=fixtures_dir / MEZH_INCOME, uploaded_by=None) if d.document_type == DocumentType.INCOME ) return balance, income async def set_amount( # noqa: PLR0913 - адресация ячейки: документ, строка, колонка session: AsyncSession, document: Document, line_code: str, amount: Decimal, *, current: bool = False, period_date: datetime.date | None = None, ) -> None: """Прямая правка суммы ячейки в БД теста (колонка `is_current` или с датой `period_date`).""" meta = ( await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) ).scalar_one() column_filter = ( FinancialStatementColumn.is_current.is_(True) if current else FinancialStatementColumn.period_date == period_date ) column_id = ( await session.execute( select(FinancialStatementColumn.id) .where(FinancialStatementColumn.meta_id == meta.id, column_filter) .order_by(FinancialStatementColumn.column_index) .limit(1) ) ).scalar_one() line_id = ( await session.execute( select(FinancialStatementLine.id).where( FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == line_code ) ) ).scalar_one() await session.execute( update(FinancialStatementValue) .where(FinancialStatementValue.line_id == line_id, FinancialStatementValue.column_id == column_id) .values(amount=amount) ) await session.flush() async def test_get_statement(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) response = await client.get(f"/documents/{document.id}/statement") assert response.status_code == HTTPStatus.OK, response.text body = response.json() assert body["edition"] == "fsbu4" assert body["document"]["document_type"] == "BALANCE" and body["document"]["period_quarter"] == "Q3" assert len(body["lines"]) == MEZH_BALANCE_LINES codes = [line["line_code"] for line in body["lines"]] assert codes == sorted(codes) columns = len(body["columns"]) assert all(len(line["values"]) == columns for line in body["lines"]) assert body["checks"]["failed"] == [] and body["checks"]["accepted"] is None assert set(body["header"]["inn"]) == {"value", "original_value", "confidence", "verified", "modified"} async def test_get_statement_shape_and_decimal_strings( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Документ, шапка и значения: поля спеки, суммы — строкой, `column_index` значения совпадает с колонкой.""" document = await form(seeded, fixtures_dir) client = await auth_client(RKA) # DOCUMENT_VIEW достаточно body = (await client.get(f"/documents/{document.id}/statement")).json() assert set(body["document"]) == { "id", "document_type", "status", "period_year", "period_quarter", "reporting_date", "counterparty_check", "locator", "file_id", } assert {"organization_name", "okud", "okpo", "okopf", "okfs", "address"} <= set(body["header"]) assert set(body["columns"][0]) == {"id", "column_index", "label", "period_date", "is_current"} line = next(line for line in body["lines"] if any(v["amount"] is not None for v in line["values"])) assert set(line) >= {"id", "line_code", "line_code_confidence", "line_name", "line_name_confidence", "values"} value = next(v for v in line["values"] if v["amount"] is not None) assert isinstance(value["amount"], str) assert set(value) == { "id", "column_id", "column_index", "amount", "string_value", "confidence", "is_recognized", "is_modified", "original_amount", "modified_by", "modified_at", } index_by_id = {column["id"]: column["column_index"] for column in body["columns"]} assert all(index_by_id[v["column_id"]] == v["column_index"] for line in body["lines"] for v in line["values"]) assert [v["column_index"] for v in line["values"]] == sorted(v["column_index"] for v in line["values"]) async def test_get_income_statement(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: document = await form(seeded, fixtures_dir, MEZH_INCOME) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() assert body["document"]["document_type"] == "INCOME" and body["edition"] == "fsbu4" assert body["lines"] async def test_statement_of_osv_is_conflict( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО ОСВ") file, job = await make_stored_file( seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done ) osv = await make_document(seeded, file=file, job=job) client = await auth_client(KA_RF) response = await client.get(f"/documents/{osv.id}/statement") assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_NOT_STATEMENT" async def test_statement_hidden_is_404( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client("client_manager@armka.local") # другой филиал assert (await client.get(f"/documents/{document.id}/statement")).status_code == HTTPStatus.NOT_FOUND async def test_cross_form_check(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: balance, _ = await two_forms(seeded, fixtures_dir) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{balance.id}/statement")).json() cross = body["checks"]["cross_form"] assert cross is None or cross["relation"] == "1370 − 1370(начало года) = 2400" async def test_cross_form_violation_is_reported_on_both_forms( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Детерминированно (D-5): 1370 = 500 на 30.09.2025 и 300 на 31.12.2024, 2400 = 150 → expected 200, actual 150.""" balance, income = await two_forms(seeded, fixtures_dir) await set_amount(seeded, balance, "1370", Decimal("500"), current=True) await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31)) await set_amount(seeded, income, "2400", Decimal("150"), current=True) client = await auth_client(KA_RF) for document in (balance, income): body = (await client.get(f"/documents/{document.id}/statement")).json() assert body["checks"]["cross_form"] == { "column_index": -1, "relation": CROSS_FORM_RELATION, "expected": "200.00", "actual": "150.00", }, document.document_type # соотношение выполнено: 500 − 300 = 200 → проверка не нарушена await set_amount(seeded, income, "2400", Decimal("200"), current=True) body = (await client.get(f"/documents/{balance.id}/statement")).json() assert body["checks"]["cross_form"] is None async def test_cross_form_without_pair_is_null( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) assert (await client.get(f"/documents/{document.id}/statement")).json()["checks"]["cross_form"] is None async def test_cross_form_ignores_superseded_pair( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """Пара из файла `SUPERSEDED` не участвует (D-5).""" balance, income = await two_forms(seeded, fixtures_dir) await set_amount(seeded, balance, "1370", Decimal("500"), current=True) await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31)) await set_amount(seeded, income, "2400", Decimal("150"), current=True) from src.models.files import StoredFile # noqa: PLC0415 await seeded.execute( update(StoredFile).where(StoredFile.id == income.stored_file_id).values(status=FileStatus.SUPERSEDED) ) await seeded.flush() client = await auth_client(KA_RF) assert (await client.get(f"/documents/{balance.id}/statement")).json()["checks"]["cross_form"] is None