arm-core/api/tests/test_form_confirmable.py
Andrey Epifancev 361e7094e3 feat(documents): порядок 409 подтверждения формы — LINES_UNRESOLVED, DUPLICATE_FORM
form_confirmable заменён на form_blocker: PERIOD_REQUIRED -> LINES_UNRESOLVED -> DUPLICATE_FORM -> CHECKS_NOT_PASSED.
Ручное и автоподтверждение используют одну функцию.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 19:04:40 +04:00

212 lines
9.7 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
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"]