feat(statements): подтверждение формы — период и проверки §7, reviewed_by; форма в чек-листе и в переходе версии (D-9)

Для BALANCE/INCOME подтверждение требует определённого периода (PERIOD_REQUIRED) и пройденных либо акцептованных
жёстких проверок §7 (CHECKS_NOT_PASSED, нарушения в detail.failures); записывает financial_statement_meta.reviewed_by/at.
Шапка читается под блокировкой документа с refresh, чтобы не использовать устаревший акцепт.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 12:31:38 +04:00
parent 6c00a8705f
commit 167969c7c9
4 changed files with 199 additions and 7 deletions

View File

@ -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="Подтвердить документ",

View File

@ -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

View File

@ -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

View File

@ -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()