"""Условия подтверждения формы ББ/ОФР: ручной и автоматический путь (OQ-157, design D-3; D-9 Среза 3b; A11).""" import json from decimal import Decimal from pathlib import Path import pytest from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from src.models.directories import LegalEntity from src.models.enums import DocumentStatus, DocumentType, StatementLineStatus from src.models.files import Document from src.models.statements import ( FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue, ) from src.services.statements import form_blocker, statement_hard_failures from tests.factories import make_legal_entity from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot pytestmark = [pytest.mark.integration, pytest.mark.fixtures] MEZH = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" SYNTH = GOLDEN_STATEMENTS / "synthetic" async def _owner(session: AsyncSession, path: Path) -> LegalEntity: """Владелец с ИНН шапки формы (MATCH).""" 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="ООО Владелец", inn=inn) async def _income(session: AsyncSession, fixtures_dir: Path) -> Document: path = fixtures_dir / MEZH / "1_Форма2_бух_09.json" (income,) = await map_golden_snapshot(session, entity=await _owner(session, path), path=path, uploaded_by=None) assert income.document_type is DocumentType.INCOME return income async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta: return ( await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) ).scalar_one() async def _unresolved_line( # noqa: PLR0913 - фабрика тестовой строки session: AsyncSession, meta: FinancialStatementMeta, *, status: StatementLineStatus, row_index: int, code_text: str | None = None, amount: Decimal | None = None, string_value: str | None = None, ) -> FinancialStatementLine: """Строка без кода (ORM-фабрика) с одной ячейкой в первой колонке формы.""" line = FinancialStatementLine(meta_id=meta.id, line_status=status, row_index=row_index, line_code_text=code_text) session.add(line) await session.flush() column = ( await session.execute( select(FinancialStatementColumn).where(FinancialStatementColumn.meta_id == meta.id).limit(1) ) ).scalar_one() session.add(FinancialStatementValue(line_id=line.id, column_id=column.id, amount=amount, string_value=string_value)) await session.flush() return line async def _confirmed_twin(session: AsyncSession, document: Document) -> Document: """Вторая форма того же файла, типа и периода со статусом `CONFIRMED` (другой `file_ordinal`).""" twin = Document( stored_file_id=document.stored_file_id, legal_entity_id=document.legal_entity_id, document_type=document.document_type, file_ordinal=document.file_ordinal + 1, period_year=document.period_year, period_quarter=document.period_quarter, status=DocumentStatus.CONFIRMED, created_at=document.created_at, updated_at=document.updated_at, ) session.add(twin) await session.flush() return twin async def _break_2200(session: AsyncSession, meta: FinancialStatementMeta) -> None: values = ( await session.execute( select(FinancialStatementValue) .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) .where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "2200") ) ).scalars() for value in values: value.amount = (value.amount or 0) + 1000 await session.flush() async def test_clean_form_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None: income = await _income(seeded, fixtures_dir) assert await form_blocker(seeded, income, await _meta(seeded, income)) is None async def test_form_without_period_is_not_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None: income = await _income(seeded, fixtures_dir) income.period_quarter = None await seeded.flush() blocker = await form_blocker(seeded, income, await _meta(seeded, income)) assert blocker is not None and blocker.code == "PERIOD_REQUIRED" async def test_form_with_hard_failure_is_not_confirmable_until_accepted( seeded: AsyncSession, fixtures_dir: Path ) -> None: income = await _income(seeded, fixtures_dir) meta = ( await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id)) ).scalar_one() # ломаем итог 2200 во всех колонках: строгое соотношение «2200 = 2100 - 2210 - 2220» перестаёт сходиться # (соотношения с 2400 в словаре мягкие — strict: false, поэтому на них жёсткое нарушение не проверить) values = ( await seeded.execute( select(FinancialStatementValue) .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) .where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "2200") ) ).scalars() for value in values: value.amount = (value.amount or 0) + 1000 await seeded.flush() assert await statement_hard_failures(seeded, income, meta) != () blocker = await form_blocker(seeded, income, meta) assert blocker is not None and blocker.code == "CHECKS_NOT_PASSED" and blocker.extra["failures"] meta.checks_accepted_at = income.created_at # акцепт: любые непустые дата и автор await seeded.flush() assert await form_blocker(seeded, income, meta) is None async def test_unresolved_line_with_amount_blocks(seeded: AsyncSession, fixtures_dir: Path) -> None: income = await _income(seeded, fixtures_dir) meta = await _meta(seeded, income) line = await _unresolved_line(seeded, meta, status=StatementLineStatus.missing, row_index=40, amount=Decimal(5)) blocker = await form_blocker(seeded, income, meta) assert blocker is not None and blocker.code == "LINES_UNRESOLVED" assert blocker.extra == {"lines": [{"line_id": str(line.id), "row_index": 40, "line_code_text": None}]} async def test_unresolved_line_with_text_only_blocks(seeded: AsyncSession, fixtures_dir: Path) -> None: income = await _income(seeded, fixtures_dir) meta = await _meta(seeded, income) await _unresolved_line( seeded, meta, status=StatementLineStatus.unreadable, row_index=41, code_text="ИЧ,250", string_value="211(1 |" ) blocker = await form_blocker(seeded, income, meta) assert blocker is not None and blocker.code == "LINES_UNRESOLVED" assert blocker.extra["lines"][0]["line_code_text"] == "ИЧ,250" async def test_unresolved_line_without_values_does_not_block(seeded: AsyncSession, fixtures_dir: Path) -> None: income = await _income(seeded, fixtures_dir) meta = await _meta(seeded, income) await _unresolved_line(seeded, meta, status=StatementLineStatus.unreadable, row_index=41) assert await form_blocker(seeded, income, meta) is None async def test_duplicate_form_blocks(seeded: AsyncSession, fixtures_dir: Path) -> None: """Review Focus 1: подтверждённая форма того же файла, типа и периода -> `DUPLICATE_FORM` с её id.""" income = await _income(seeded, fixtures_dir) twin = await _confirmed_twin(seeded, income) blocker = await form_blocker(seeded, income, await _meta(seeded, income)) assert blocker is not None and blocker.code == "DUPLICATE_FORM" and blocker.extra == {"document_id": str(twin.id)} async def test_other_period_twin_does_not_block(seeded: AsyncSession, fixtures_dir: Path) -> None: income = await _income(seeded, fixtures_dir) twin = await _confirmed_twin(seeded, income) twin.period_year = (income.period_year or 0) - 1 await seeded.flush() assert await form_blocker(seeded, income, await _meta(seeded, income)) is None async def test_conflict_order(seeded: AsyncSession, fixtures_dir: Path) -> None: """PERIOD_REQUIRED -> LINES_UNRESOLVED -> DUPLICATE_FORM -> CHECKS_NOT_PASSED.""" income = await _income(seeded, fixtures_dir) meta = await _meta(seeded, income) await _break_2200(seeded, meta) await _confirmed_twin(seeded, income) await _unresolved_line(seeded, meta, status=StatementLineStatus.missing, row_index=40, amount=Decimal(5)) quarter = income.period_quarter income.period_quarter = None await seeded.flush() codes = [] codes.append((await form_blocker(seeded, income, meta)).code) # type: ignore[union-attr] income.period_quarter = quarter await seeded.flush() codes.append((await form_blocker(seeded, income, meta)).code) # type: ignore[union-attr] await seeded.execute(delete(FinancialStatementValue).where(FinancialStatementValue.amount == Decimal(5))) await seeded.flush() codes.append((await form_blocker(seeded, income, meta)).code) # type: ignore[union-attr] await seeded.execute(delete(Document).where(Document.status == DocumentStatus.CONFIRMED)) await seeded.flush() codes.append((await form_blocker(seeded, income, meta)).code) # type: ignore[union-attr] assert codes == ["PERIOD_REQUIRED", "LINES_UNRESOLVED", "DUPLICATE_FORM", "CHECKS_NOT_PASSED"]