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
212 lines
9.7 KiB
Python
212 lines
9.7 KiB
Python
"""Условия подтверждения формы ББ/ОФР: ручной и автоматический путь (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"]
|