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:
parent
6c00a8705f
commit
167969c7c9
@ -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="Подтвердить документ",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user