arm-core/api/tests/test_form_confirmable.py
Andrey Epifancev 065153e470 feat(statements): пересчёт после правки точкой входа arm_statements, §7 только из document_issue
recompute_form (services/statement_recompute.py) собирает вход из БД, зовёт engine.recompute и пишет needs_review
строк и значений, form_variant, parent_row_index расшифровок, записи relation/soft и core по P3-8, статус через
form_status_v2. GET/confirm/accept читают нарушения §7 из document_issue. Правка header.report_date пересчитывает
relative:* колонки. Временный код A12/A13 удалён.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:26:40 +04:00

225 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Условия подтверждения формы ББ/ОФР: ручной и автоматический путь (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, RecognitionJob
from src.models.statements import (
FinancialStatementColumn,
FinancialStatementLine,
FinancialStatementMeta,
FinancialStatementValue,
)
from src.services.statement_recompute import recompute_form
from src.services.statements import form_blocker, statement_hard_failures
from src.services.statements_engine import default_engine
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 _recompute(session: AsyncSession, document: Document, meta: FinancialStatementMeta) -> None:
"""§7 хранится в `document_issue`: после прямой правки сумм в БД теста нарушения появляются пересчётом."""
job = await session.get(RecognitionJob, meta.recognition_job_id)
await recompute_form(session, document=document, meta=meta, engine=default_engine(), job=job)
await session.flush()
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()
document = await session.get(Document, meta.document_id)
assert document is not None
await _recompute(session, document, meta)
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()
await _recompute(seeded, income, meta)
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"]