diff --git a/api/src/api/endpoints/files/documents.py b/api/src/api/endpoints/files/documents.py index 244d214..981de94 100644 --- a/api/src/api/endpoints/files/documents.py +++ b/api/src/api/endpoints/files/documents.py @@ -59,7 +59,8 @@ async def list_entries( **NOT_FOUND, status.HTTP_409_CONFLICT: { "model": ErrorDetail, - "description": "`ALREADY_CONFIRMED`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_CONFIRMABLE`", + "description": "`ALREADY_CONFIRMED`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_CONFIRMABLE`; для формы ББ/ОФР — " + "`PERIOD_REQUIRED`, `CHECKS_NOT_PASSED` (нарушения проверок — в `detail.failures`)", }, }, summary="Подтвердить документ", diff --git a/api/src/services/documents.py b/api/src/services/documents.py index 15fcfce..beedae9 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -23,7 +23,9 @@ from src.domain.policy import VisibilityScope from src.models.enums import AuditAction, AuditSection, DocumentStatus, FileStatus from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry +from src.models.statements import FinancialStatementMeta from src.repositories import files as files_repo +from src.repositories import statements as statements_repo from src.schemas.documents import DocumentOut, EntriesPageParams, GroupOut from src.schemas.files import DocumentBrief, FileOut, JobBrief from src.services import consolidation_documents @@ -35,6 +37,8 @@ WARNINGS_KEY = "warnings" # `recognition_job.metrics.warnings` — предуп FILE_SUPERSEDED = "FILE_SUPERSEDED" ALREADY_CONFIRMED = "ALREADY_CONFIRMED" DOCUMENT_NOT_CONFIRMABLE = "DOCUMENT_NOT_CONFIRMABLE" +PERIOD_REQUIRED = "PERIOD_REQUIRED" +CHECKS_NOT_PASSED = "CHECKS_NOT_PASSED" CONFIRMABLE_STATUSES = frozenset({DocumentStatus.RECOGNIZED, DocumentStatus.NEEDS_CHECK}) @@ -146,13 +150,47 @@ class DocumentService: document = await self._visible(document_id) return await files_repo.list_entries(self._session, document.id, limit=page.limit, offset=page.offset) + async def _check_statement(self, document: Document) -> FinancialStatementMeta | None: + """Условия подтверждения формы ББ/ОФР (D-9): период определён, жёсткие проверки §7 пройдены или акцептованы. + + Шапка читается под блокировками документа с `refresh` (иначе в сессии мог остаться устаревший акцепт). + Для не-формы и документа без шапки — `None`, условий нет. + """ + from src.services.statements import FORM_TYPES, StatementService # noqa: PLC0415 - цикл импорта + + if document.document_type not in FORM_TYPES: + return None + meta = await statements_repo.get_meta(self._session, document.id, refresh=True) + if meta is None: + 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) + if failures: + raise ConflictError( + CHECKS_NOT_PASSED, + "не пройдены логические проверки формы: исправьте значения или акцептуйте с комментарием", + failures=[ + { + "column_index": failure.column_index, + "relation": failure.relation, + "expected": str(failure.expected), + "actual": str(failure.actual), + } + for failure in failures + ], + ) + return meta + async def confirm(self, document_id: uuid.UUID) -> DocumentOut: """`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED` (`confirmed_by/at`), аудит `DOCUMENT_CONFIRM`. 409: файл `SUPERSEDED` — `FILE_SUPERSEDED`; уже `CONFIRMED` — `ALREADY_CONFIRMED`; иной статус — - `DOCUMENT_NOT_CONFIRMABLE`. Порядок блокировок и перечитывание — см. docstring модуля. В той же транзакции - документ привязывается к подходящим версиям «в работе», версии из `INITIATED`/`DOCUMENTS_PROCESSING` переходят - в `DATA_CORRECTION` (`on_document_confirmed`, spec documents-api «Подтверждение документа»). + `DOCUMENT_NOT_CONFIRMABLE`; для формы ББ/ОФР далее — `PERIOD_REQUIRED` (период не определён) и + `CHECKS_NOT_PASSED` (неакцептованные нарушения проверок §7 в `failures`; D-9). Порядок блокировок и + перечитывание — см. docstring модуля. В той же транзакции документ привязывается к подходящим версиям + «в работе», версии из `INITIATED`/`DOCUMENTS_PROCESSING` переходят в `DATA_CORRECTION` + (`on_document_confirmed`, spec documents-api «Подтверждение документа»). """ visible = await self._visible(document_id) file = await files_repo.lock_file(self._session, visible.stored_file_id) @@ -170,12 +208,16 @@ class DocumentService: DOCUMENT_NOT_CONFIRMABLE, f"документ в статусе {document.status.value} подтвердить нельзя: нужен RECOGNIZED или NEEDS_CHECK", ) + meta = await self._check_statement(document) before = document.status now = utc_now() document.status = DocumentStatus.CONFIRMED document.confirmed_by = self._audit.user_id document.confirmed_at = now document.updated_at = now + if meta is not None: + meta.reviewed_by = self._audit.user_id + meta.reviewed_at = now await self._session.flush() await consolidation_documents.on_document_confirmed( self._session, self._audit.service, document=document, user_id=self._audit.user_id, now=now diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 6a5c1de..5a6d69e 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -15,8 +15,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.domain.statement_checks import CROSS_FORM_RELATION from src.models.access import AppUser from src.models.audit_ex import AuditEvent -from src.models.directories import LegalEntity, RegionalOffice -from src.models.enums import AuditAction, DocumentStatus, DocumentType, FileStatus, RecognitionStatus +from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice +from src.models.enums import AuditAction, DocumentStatus, DocumentType, FileStatus, PeriodQuarter, RecognitionStatus from src.models.files import Document, StoredFile from src.models.statements import ( FinancialStatementColumn, @@ -25,7 +25,13 @@ from src.models.statements import ( FinancialStatementValue, ) from tests.conftest import AuthClientFactory -from tests.factories import make_company_group, make_document, make_legal_entity, make_stored_file +from tests.factories import ( + make_company_group, + make_consolidation, + make_document, + make_legal_entity, + make_stored_file, +) from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot pytestmark = [pytest.mark.integration, pytest.mark.fixtures] @@ -600,3 +606,110 @@ async def test_patch_osv_is_not_statement( client = await auth_client(KA_RF) response = await client.patch(f"/documents/{osv.id}/statement", json={"header": {"units": "руб."}}) assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_NOT_STATEMENT" + + +async def test_confirm_requires_checks( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + await _break_balance(client, document.id) + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED" + failures = blocked.json()["detail"]["failures"] + assert failures and set(failures[0]) == {"column_index", "relation", "expected", "actual"} + assert all(isinstance(failure["expected"], str) and isinstance(failure["actual"], str) for failure in failures) + status = await seeded.scalar(select(Document.status).where(Document.id == document.id)) + assert status is not DocumentStatus.CONFIRMED + await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "округление"}) + confirmed = await client.post(f"/documents/{document.id}/confirm") + assert confirmed.status_code == HTTPStatus.OK and confirmed.json()["status"] == "CONFIRMED" + meta = ( + await seeded.execute( + select(FinancialStatementMeta) + .where(FinancialStatementMeta.document_id == document.id) + .execution_options(populate_existing=True) + ) + ).scalar_one() + assert meta.reviewed_by is not None and meta.reviewed_at is not None + + +async def test_confirm_after_non_quarter_date_edit( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Review Focus 5: дата не конец квартала → квартал NULL, NEEDS_CHECK, PERIOD_REQUIRED; исправление даты снимает.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + edit = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-10-31"}}) + body = edit.json() + assert body["document"]["period_quarter"] is None and body["document"]["status"] == "NEEDS_CHECK" + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "PERIOD_REQUIRED" + await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-09-30"}}) + assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK + + +async def test_period_required_precedes_checks_not_passed( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Порядок D-9: при пустом периоде и нарушенной проверке — `PERIOD_REQUIRED`.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + await _break_balance(client, document.id) + await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-10-31"}}) + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "PERIOD_REQUIRED" + + +async def test_confirm_form_rereads_accept_under_lock( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Акцепт, поставленный параллельным запросом после загрузки шапки в сессию, виден подтверждению (T6.I1).""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + await _break_balance(client, document.id) + held = ( # карта идентичности слабая: удерживаем шапку без акцепта, как это делает ждущий запрос + await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) + ).scalar_one() + assert held.checks_accepted_at is None + user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one() + await seeded.execute( + update(FinancialStatementMeta) + .where(FinancialStatementMeta.document_id == document.id) + .values( + checks_accepted_by=user_id, + checks_accepted_at=datetime.datetime.now(datetime.UTC), + checks_accepted_comment="x", + ) + .execution_options(synchronize_session=False) # объект в сессии остаётся устаревшим + ) + confirmed = await client.post(f"/documents/{document.id}/confirm") + assert confirmed.status_code == HTTPStatus.OK, confirmed.text + + +async def test_confirmed_form_in_checklist( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + entity = await seeded.get(LegalEntity, document.legal_entity_id) + assert entity is not None + group = await seeded.get(CompanyGroup, entity.company_group_id) + assert group is not None + user = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one() + _, version = await make_consolidation( + seeded, group=group, year=2025, quarter=PeriodQuarter.Q3, members=[entity], initiator=user + ) + client = await auth_client(KA_RF) + assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK + checklist = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json() + balance_index = next(i for i, rule in enumerate(checklist["rules"]) if rule["document_type"] == "BALANCE") + row = next(row for row in checklist["rows"] if row["legal_entity_id"] == str(entity.id)) + assert row["cells"][balance_index]["status"] == "CONFIRMED" + meta = ( + await seeded.execute( + select(FinancialStatementMeta) + .where(FinancialStatementMeta.document_id == document.id) + .execution_options(populate_existing=True) + ) + ).scalar_one() + assert meta.reviewed_by == user.id diff --git a/api/tests/test_consolidation_documents.py b/api/tests/test_consolidation_documents.py index cde20a7..d3c091c 100644 --- a/api/tests/test_consolidation_documents.py +++ b/api/tests/test_consolidation_documents.py @@ -11,6 +11,7 @@ import asyncio import datetime import uuid from collections.abc import Sequence +from pathlib import Path from typing import Any import pytest @@ -57,6 +58,7 @@ from tests.factories import ( make_related_group, make_stored_file, ) +from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot HTTP_OK = 200 HTTP_CREATED = 201 @@ -64,6 +66,8 @@ ROSTOV = "Ростовский региональный филиал" KA_RF = "ka_rf@armka.local" ACCOUNT = "60" YEAR = 2024 +FORM_YEAR = 2025 # период golden-формы `FORM_SNAPSHOT` +FORM_SNAPSHOT = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json" LOCK_WAIT_SECONDS = 5 ROWS = [{"name": "ООО Контрагент", "inn": "7701234567", "closing_debit": 10}] Q1, Q2, Q3, Q4 = PeriodQuarter.Q1, PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4 @@ -1138,3 +1142,35 @@ async def test_on_documents_mapped_waits_for_transition_to_documents_processing( assert moved == [data["version_id"]] async with maker() as session: assert await _status(session, data["version_id"]) is ConsolidationStatus.DATA_CORRECTION + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_mapped_statement_moves_version_without_binding(seeded: AsyncSession, fixtures_dir: Path) -> None: + """Форма двигает версию: `BALANCE` Q3 в `NEEDS_CHECK` (ИНН шапки ≠ ИНН владельца) → `DATA_CORRECTION`.""" + group, entities = await _group(seeded) + user = await _user(seeded) + _, version = await make_consolidation( + seeded, + group=group, + year=FORM_YEAR, + quarter=Q3, + members=entities, + initiator=user, + status=ConsolidationStatus.DOCUMENTS_PROCESSING, + ) + documents = await map_golden_snapshot( + seeded, entity=entities[0], path=fixtures_dir / FORM_SNAPSHOT, uploaded_by=user.id + ) + balance = next(d for d in documents if d.document_type is DocumentType.BALANCE) + assert (balance.status, balance.period_year, balance.period_quarter) == (DocumentStatus.NEEDS_CHECK, FORM_YEAR, Q3) + file = await seeded.get(StoredFile, balance.stored_file_id) + assert file is not None + # `map_file` сам перехода не делает: он идёт после него, в `map_job_result` + assert await _status(seeded, version.id) is ConsolidationStatus.DOCUMENTS_PROCESSING + + moved = await consolidation_documents.on_documents_mapped(seeded, file=file, documents=documents, now=utc_now()) + + assert moved == [version.id] + assert await _status(seeded, version.id) is ConsolidationStatus.DATA_CORRECTION + assert await _bound(seeded, version.id) == set()