diff --git a/api/src/services/documents.py b/api/src/services/documents.py index aae4ec0..393fd60 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -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, diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 15d964f..9288c12 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -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` не трогает. diff --git a/api/tests/test_form_confirmable.py b/api/tests/test_form_confirmable.py new file mode 100644 index 0000000..67cd9f5 --- /dev/null +++ b/api/tests/test_form_confirmable.py @@ -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