refactor(statements): form_confirmable — условия подтверждения формы для ручного и автоматического пути (#64)

Условия подтверждения формы ББ/ОФР вынесены в statement_hard_failures и form_confirmable
(модульные функции services/statements.py); ручной путь (documents._check_statement) использует
тот же statement_hard_failures и отвечает 409. Тест с нарушением строгого соотношения на 2200
(соотношения с 2400 в словаре мягкие).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-08 16:57:35 +04:00
parent ff65f2108b
commit 588c098838
3 changed files with 111 additions and 7 deletions

View File

@ -209,7 +209,7 @@ class DocumentService:
Шапка читается под блокировками документа с `refresh` (иначе в сессии мог остаться устаревший акцепт).
Для не-формы и документа без шапки — `None`, условий нет.
"""
from src.services.statements import FORM_TYPES, StatementService # noqa: PLC0415 - цикл импорта
from src.services.statements import FORM_TYPES, statement_hard_failures # noqa: PLC0415 - цикл импорта
if document.document_type not in FORM_TYPES:
return None
@ -218,7 +218,7 @@ class DocumentService:
return None
if document.period_year is None or document.period_quarter is None:
raise ConflictError(PERIOD_REQUIRED, "у формы не определён период: поправьте дату отчёта")
failures = await StatementService(self._session, self._audit, self._scope).hard_failures(document, meta)
failures = await statement_hard_failures(self._session, document, meta)
if failures:
raise ConflictError(
CHECKS_NOT_PASSED,

View File

@ -47,6 +47,31 @@ INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII)
FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME})
async def statement_hard_failures(
session: AsyncSession, document: Document, meta: FinancialStatementMeta
) -> tuple[FailedCheck, ...]:
"""Нарушенные жёсткие проверки §7 по текущим суммам формы; при акцепте — пустой кортеж (D-8 Среза 3b)."""
if meta.checks_accepted_at is not None:
return ()
column_indexes, amounts = await repo.statement_amounts(session, meta.id)
report = run_checks(document.document_type.value, amounts, column_indexes, load_check_rules())
return tuple(report.failed)
async def form_confirmable(session: AsyncSession, document: Document) -> bool:
"""Условия подтверждения формы ББ/ОФР без исключений (D-9 Среза 3b; автоподтверждение — design D-3, OQ-157):
период определён и жёстких нарушений §7 нет или они акцептованы. Не-форма и форма без шапки (созданная до Среза 3b)
— `True`: условий у них нет. Ручной путь (`DocumentService._check_statement`) проверяет то же, но отвечает 409."""
if document.document_type not in FORM_TYPES:
return True
meta = await repo.get_meta(session, document.id, refresh=True)
if meta is None:
return True
if document.period_year is None or document.period_quarter is None:
return False
return not await statement_hard_failures(session, document, meta)
class StatementService:
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
self._session = session
@ -107,11 +132,7 @@ class StatementService:
async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]:
"""Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы)."""
if meta.checks_accepted_at is not None:
return ()
column_indexes, amounts = await repo.statement_amounts(self._session, meta.id)
report = run_checks(document.document_type.value, amounts, column_indexes, load_check_rules())
return tuple(report.failed)
return await statement_hard_failures(self._session, document, meta)
async def recompute_status(self, document: Document, meta: FinancialStatementMeta) -> None:
"""D-6 после правки или акцепта; `CONFIRMED` не трогает.

View File

@ -0,0 +1,83 @@
"""Условия подтверждения формы ББ/ОФР — общие для ручного и автоматического пути (OQ-157, design D-3; D-9 Среза 3b)."""
import json
from pathlib import Path
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.directories import LegalEntity
from src.models.enums import DocumentType
from src.models.files import Document
from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue
from src.services.statements import form_confirmable, 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 test_clean_form_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None:
assert await form_confirmable(seeded, await _income(seeded, fixtures_dir)) is True
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()
assert await form_confirmable(seeded, income) is False
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) != ()
assert await form_confirmable(seeded, income) is False
meta.checks_accepted_at = income.created_at # акцепт: любые непустые дата и автор
await seeded.flush()
assert await form_confirmable(seeded, income) is True
async def test_disclosure_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None:
owner = await make_legal_entity(seeded, name="ООО Синтетика")
documents = await map_golden_snapshot(
seeded, entity=owner, path=fixtures_dir / SYNTH / "st06_header_variants.json", uploaded_by=None
)
disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE)
assert await form_confirmable(seeded, disclosure) is True