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:
parent
ff65f2108b
commit
588c098838
@ -209,7 +209,7 @@ class DocumentService:
|
|||||||
Шапка читается под блокировками документа с `refresh` (иначе в сессии мог остаться устаревший акцепт).
|
Шапка читается под блокировками документа с `refresh` (иначе в сессии мог остаться устаревший акцепт).
|
||||||
Для не-формы и документа без шапки — `None`, условий нет.
|
Для не-формы и документа без шапки — `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:
|
if document.document_type not in FORM_TYPES:
|
||||||
return None
|
return None
|
||||||
@ -218,7 +218,7 @@ class DocumentService:
|
|||||||
return None
|
return None
|
||||||
if document.period_year is None or document.period_quarter is None:
|
if document.period_year is None or document.period_quarter is None:
|
||||||
raise ConflictError(PERIOD_REQUIRED, "у формы не определён период: поправьте дату отчёта")
|
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:
|
if failures:
|
||||||
raise ConflictError(
|
raise ConflictError(
|
||||||
CHECKS_NOT_PASSED,
|
CHECKS_NOT_PASSED,
|
||||||
|
|||||||
@ -47,6 +47,31 @@ INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII)
|
|||||||
FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME})
|
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:
|
class StatementService:
|
||||||
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
|
def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None:
|
||||||
self._session = session
|
self._session = session
|
||||||
@ -107,11 +132,7 @@ class StatementService:
|
|||||||
|
|
||||||
async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]:
|
async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]:
|
||||||
"""Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы)."""
|
"""Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы)."""
|
||||||
if meta.checks_accepted_at is not None:
|
return await statement_hard_failures(self._session, document, meta)
|
||||||
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)
|
|
||||||
|
|
||||||
async def recompute_status(self, document: Document, meta: FinancialStatementMeta) -> None:
|
async def recompute_status(self, document: Document, meta: FinancialStatementMeta) -> None:
|
||||||
"""D-6 после правки или акцепта; `CONFIRMED` не трогает.
|
"""D-6 после правки или акцепта; `CONFIRMED` не трогает.
|
||||||
|
|||||||
83
api/tests/test_form_confirmable.py
Normal file
83
api/tests/test_form_confirmable.py
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user