feat(statements): правка формы (значения, ИНН, дата, единицы) и акцепт проверок — аудит, пересчёт статуса (D-7, D-8)

PATCH /documents/{id}/statement и POST /documents/{id}/statement/checks/accept: блокировки «файл → документ»,
409 FILE_SUPERSEDED/DOCUMENT_READONLY/DOCUMENT_NOT_STATEMENT, 422 INVALID_INN/UNKNOWN_CELL/DUPLICATE_CELL,
один audit_event на запрос, сброс акцепта правкой, пересчёт статуса D-6.

Замечания ревью Task 5: пара формы ищется только среди документов с шапкой формы (T5.1), строки формы упорядочены
по (line_code, id) (T5.3), слабый тест межформенной проверки удалён (T5.4), локальный импорт в тестах убран (T5.6).

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:09:43 +04:00
parent 2b5e70f7c3
commit 38a6a1b0c2
6 changed files with 527 additions and 23 deletions

View File

@ -1,7 +1,8 @@
"""`/documents/{document_id}/statement` — форма ББ/ОФР для экрана проверки (spec documents-api «Форма ББ/ОФР для
экрана проверки»; design D-5, D-14).
экрана проверки», «Правка формы», «Акцепт проверок формы»; design D-5, D-7, D-8, D-14).
Чтение — `DOCUMENT_VIEW`; право проверяется до видимости, документ ЮЛ вне видимости — тот же 404, что несуществующий.
Чтение — `DOCUMENT_VIEW`, правка и акцепт — `DOCUMENT_EDIT`; право проверяется до видимости, документ ЮЛ вне
видимости — тот же 404, что несуществующий.
"""
import uuid
@ -12,15 +13,28 @@ from fastapi import APIRouter, Depends, status
from src.api.dependencies.auth import require
from src.api.dependencies.files import StatementServiceDep
from src.schemas.common import ErrorDetail
from src.schemas.statements import StatementOut
from src.schemas.statements import ChecksAcceptIn, StatementOut, StatementPatch
router = APIRouter(prefix="/documents")
VIEW = [Depends(require("DOCUMENT_VIEW"))]
EDIT = [Depends(require("DOCUMENT_EDIT"))]
STATEMENT_ERRORS: dict[int | str, dict[str, Any]] = {
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"},
status.HTTP_409_CONFLICT: {"model": ErrorDetail, "description": "`DOCUMENT_NOT_STATEMENT`"},
}
EDIT_ERRORS: dict[int | str, dict[str, Any]] = {
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"},
status.HTTP_409_CONFLICT: {
"model": ErrorDetail,
"description": "`FILE_SUPERSEDED`, `DOCUMENT_READONLY`, `DOCUMENT_NOT_CONFIRMABLE`, `DOCUMENT_NOT_STATEMENT`, "
"`NOTHING_TO_ACCEPT`",
},
status.HTTP_422_UNPROCESSABLE_CONTENT: {
"model": ErrorDetail,
"description": "`INVALID_INN`, `UNKNOWN_CELL`, `DUPLICATE_CELL` и ошибки схемы",
},
}
@router.get(
@ -33,3 +47,31 @@ STATEMENT_ERRORS: dict[int | str, dict[str, Any]] = {
async def get_statement(document_id: uuid.UUID, service: StatementServiceDep) -> StatementOut:
"""Шапка, колонки, строки со значениями, редакция формы и проверки §7 (в т.ч. межформенная)."""
return await service.get(document_id)
@router.patch(
"/{document_id}/statement",
response_model=StatementOut,
dependencies=EDIT,
responses=EDIT_ERRORS,
summary="Правка формы ББ/ОФР",
)
async def patch_statement(document_id: uuid.UUID, body: StatementPatch, service: StatementServiceDep) -> StatementOut:
"""Значения и шапка (`inn`, `report_date`, `units`): аудит, сброс акцепта, пересчёт статуса.
Запрос без изменений — 200 без аудита."""
return await service.patch(document_id, body)
@router.post(
"/{document_id}/statement/checks/accept",
response_model=StatementOut,
dependencies=EDIT,
responses=EDIT_ERRORS,
summary="Акцепт непройденных проверок формы",
)
async def accept_statement_checks(
document_id: uuid.UUID, body: ChecksAcceptIn, service: StatementServiceDep
) -> StatementOut:
"""Акцепт жёстких проверок §7 с комментарием; без нарушений — 409 `NOTHING_TO_ACCEPT`."""
return await service.accept_checks(document_id, body)

View File

@ -3,6 +3,7 @@
import datetime
import uuid
from collections.abc import Sequence
from decimal import Decimal
from typing import Any
@ -25,6 +26,7 @@ __all__ = [
"get_meta",
"insert_statement",
"load_form",
"lock_cells",
"pair_amounts",
"statement_amounts",
"value_flags",
@ -151,6 +153,35 @@ async def value_flags(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[b
return [(bool(modified), recognized, confidence) for modified, recognized, confidence in rows]
async def lock_cells(
session: AsyncSession, meta_id: uuid.UUID, cells: Sequence[tuple[uuid.UUID, uuid.UUID]]
) -> dict[tuple[uuid.UUID, uuid.UUID], FinancialStatementValue]:
"""Значения формы `meta_id` по `(line_id, column_id)` под `FOR UPDATE` с перечитыванием; чужие ячейки (строка не
этой формы или колонка не её) в словарь не попадают."""
if not cells:
return {}
line_ids = {line_id for line_id, _ in cells}
stmt = (
select(FinancialStatementValue)
.join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id)
.join(FinancialStatementColumn, FinancialStatementColumn.id == FinancialStatementValue.column_id)
.where(
FinancialStatementLine.meta_id == meta_id,
FinancialStatementColumn.meta_id == meta_id,
FinancialStatementValue.line_id.in_(line_ids),
)
.order_by(FinancialStatementValue.id)
.with_for_update(of=FinancialStatementValue)
.execution_options(populate_existing=True)
)
wanted = set(cells)
return {
(value.line_id, value.column_id): value
for value in (await session.execute(stmt)).scalars()
if (value.line_id, value.column_id) in wanted
}
async def get_meta(session: AsyncSession, document_id: uuid.UUID) -> FinancialStatementMeta | None:
"""Шапка формы документа (1:1) или `None`."""
stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document_id)
@ -176,7 +207,7 @@ async def load_form(
await session.execute(
select(FinancialStatementLine)
.where(FinancialStatementLine.meta_id == meta_id)
.order_by(FinancialStatementLine.line_code)
.order_by(FinancialStatementLine.line_code, FinancialStatementLine.id)
)
).scalars()
)
@ -219,10 +250,11 @@ async def _line_amount(
async def _pair_document(session: AsyncSession, document: Document, pair_type: DocumentType) -> Document | None:
"""Форма-пара (D-5): другой тип формы того же ЮЛ, года и квартала, файл не `SUPERSEDED`; подтверждённая первой,
затем самая поздняя по `created_at`."""
затем самая поздняя по `created_at`. Документ без шапки формы не считается парой (иначе он затенял бы настоящую)."""
stmt = (
select(Document)
.join(StoredFile, StoredFile.id == Document.stored_file_id)
.join(FinancialStatementMeta, FinancialStatementMeta.document_id == Document.id)
.where(
Document.legal_entity_id == document.legal_entity_id,
Document.period_year == document.period_year,

View File

@ -10,7 +10,7 @@ import uuid
from collections.abc import Sequence
from decimal import Decimal
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from src.domain.statement_checks import ChecksReport, FailedCheck
from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter
@ -224,3 +224,36 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
accepted=_accepted(meta),
),
)
class CellPatch(BaseModel):
"""Новая сумма ячейки: не более двух знаков и 16 цифр до запятой (`numeric(18,2)`), `null` — очистить."""
model_config = ConfigDict(extra="forbid")
line_id: uuid.UUID
column_id: uuid.UUID
amount: Decimal | None = Field(max_digits=18, decimal_places=2)
class HeaderPatch(BaseModel):
"""Отсутствующее поле не меняется; `null` у `report_date`/`inn` — очистить значение (`model_fields_set`)."""
model_config = ConfigDict(extra="forbid")
inn: str | None = None
report_date: datetime.date | None = None
units: str | None = Field(None, min_length=1, max_length=50)
class StatementPatch(BaseModel):
model_config = ConfigDict(extra="forbid")
header: HeaderPatch | None = None
values: list[CellPatch] = Field(default_factory=list, max_length=5000)
class ChecksAcceptIn(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
comment: str = Field(min_length=1, max_length=2000)

View File

@ -4,21 +4,36 @@
Проверки §7 считаются на лету и не хранятся.
"""
import re
import uuid
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.audit import RequestAudit
from src.common.exceptions import ConflictError, NotFoundError
from src.common.clock import utc_now
from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError
from src.domain.osv_mapping import counterparty_check
from src.domain.policy import VisibilityScope
from src.domain.statement_checks import ChecksReport, FailedCheck, cross_form_check, load_check_rules, run_checks
from src.domain.statement_mapping import edition_for
from src.models.enums import DocumentType
from src.models.files import Document
from src.domain.statement_mapping import edition_for, quarter_of, statement_period, statement_status, values_ok
from src.models.directories import LegalEntity
from src.models.enums import (
AuditAction,
AuditSection,
DocumentCheckResult,
DocumentStatus,
DocumentType,
FileStatus,
RecognitionStatus,
)
from src.models.files import Document, RecognitionJob
from src.models.statements import FinancialStatementMeta
from src.repositories import files as files_repo
from src.repositories import statements as repo
from src.repositories.counterparties import CounterpartyIndex
from src.schemas import statements as schemas
from src.services.documents import CONFIRMABLE_STATUSES, DOCUMENT_NOT_CONFIRMABLE, FILE_SUPERSEDED
DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего
DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT"
@ -27,6 +42,8 @@ UNKNOWN_CELL = "UNKNOWN_CELL"
DUPLICATE_CELL = "DUPLICATE_CELL"
NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT"
INVALID_INN = "INVALID_INN"
OBJECT_TYPE = "document"
INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII)
FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME})
@ -46,6 +63,24 @@ class StatementService:
raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР")
return document, meta
async def _editable(self, document_id: uuid.UUID) -> tuple[Document, FinancialStatementMeta]:
"""Видимость → блокировки «файл → документ» → проверки D-7 (порядок 409 как у подтверждения)."""
visible, _ = await self._visible_statement(document_id)
file = await files_repo.lock_file(self._session, visible.stored_file_id)
document = await files_repo.lock_document(self._session, document_id)
if file is None or document is None:
raise NotFoundError(DOCUMENT_NOT_FOUND)
if file.status is FileStatus.SUPERSEDED:
raise ConflictError(FILE_SUPERSEDED, "файл документа заменён новой версией")
if document.status is DocumentStatus.CONFIRMED:
raise ConflictError(DOCUMENT_READONLY, "подтверждённая форма только для чтения: загрузите новый файл")
if document.status not in CONFIRMABLE_STATUSES:
raise ConflictError(DOCUMENT_NOT_CONFIRMABLE, f"форма в статусе {document.status.value} не правится")
meta = await repo.get_meta(self._session, document.id)
if meta is None:
raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР")
return document, meta
async def checks_report(
self, document: Document, meta: FinancialStatementMeta
) -> tuple[ChecksReport, FailedCheck | None]:
@ -67,3 +102,135 @@ class StatementService:
return schemas.statement_out(
document, meta, columns, lines, report, cross, edition=edition_for(document.reporting_date)
)
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)
async def recompute_status(self, document: Document, meta: FinancialStatementMeta) -> None:
"""D-6 после правки или акцепта; `CONFIRMED` не трогает.
Предупреждения маппинга файла не хранятся на уровне документа, поэтому передаются пустыми (`warnings=()`).
"""
if document.status is DocumentStatus.CONFIRMED:
return
job = await self._session.get(RecognitionJob, meta.recognition_job_id)
_, amounts = await repo.statement_amounts(self._session, meta.id)
document.status = statement_status(
job_done=job is not None and job.status is RecognitionStatus.done,
checks_passed=not await self.hard_failures(document, meta),
counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN,
period_known=document.period_year is not None and document.period_quarter is not None,
values_ok=values_ok(await repo.value_flags(self._session, meta.id)),
has_lines=bool(amounts),
warnings=(),
)
document.updated_at = utc_now()
async def _apply_cells(
self, meta: FinancialStatementMeta, body: schemas.StatementPatch, before: dict[str, Any], after: dict[str, Any]
) -> None:
"""Значения `body.values` (D-7): `original_amount` при первой правке, `is_modified`, `modified_by/at`."""
keys = [(cell.line_id, cell.column_id) for cell in body.values]
if len(keys) != len(set(keys)):
raise DomainValidationError(DUPLICATE_CELL, "ячейка указана в запросе дважды")
cells = await repo.lock_cells(self._session, meta.id, keys)
if len(cells) != len(set(keys)):
raise DomainValidationError(UNKNOWN_CELL, "ячейка не принадлежит форме")
now = utc_now()
for cell in body.values:
value = cells[(cell.line_id, cell.column_id)]
if value.amount == cell.amount:
continue
key = f"value:{value.id}"
before[key], after[key] = value.amount, cell.amount
if not value.is_modified and value.original_amount is None:
value.original_amount = value.amount
value.amount = cell.amount
value.is_modified = value.amount != value.original_amount
value.modified_by, value.modified_at = self._audit.user_id, now
async def _apply_header(
self,
document: Document,
meta: FinancialStatementMeta,
header: schemas.HeaderPatch,
before: dict[str, Any],
after: dict[str, Any],
) -> None:
"""Поля шапки (D-7): `*_modified = true`, `*_original_value` не трогаем; пересчёт контрагента и периода."""
fields = header.model_fields_set
if "inn" in fields and header.inn != meta.inn:
before["inn"], after["inn"] = meta.inn, header.inn
meta.inn, meta.inn_modified = header.inn, True
owner = await self._session.get(LegalEntity, document.legal_entity_id)
if owner is None:
raise NotFoundError(DOCUMENT_NOT_FOUND)
index = await CounterpartyIndex.load(self._session)
document.counterparty_check = counterparty_check(
meta.inn, meta.organization_name, owner.inn, index.owner_names(owner)
)
if "report_date" in fields and header.report_date != meta.report_date:
before["report_date"], after["report_date"] = meta.report_date, header.report_date
meta.report_date, meta.report_date_modified = header.report_date, True
columns, _ = await repo.load_form(self._session, meta.id)
period = statement_period(meta.report_date, [column.period_date for column in columns])
document.reporting_date = period
document.period_year = period.year if period is not None else None
document.period_quarter = quarter_of(period) if period is not None else None
if "units" in fields and header.units is not None and header.units != meta.units:
before["units"], after["units"] = meta.units, header.units
meta.units, meta.units_modified = header.units, True
async def patch(self, document_id: uuid.UUID, body: schemas.StatementPatch) -> schemas.StatementOut:
"""Правка значений и шапки (D-7): одна транзакция, один `audit_event`, сброс акцепта, пересчёт статуса."""
document, meta = await self._editable(document_id)
header = body.header
if header is not None and header.inn is not None and not INN_RE.fullmatch(header.inn):
raise DomainValidationError(INVALID_INN, "ИНН — 10 или 12 цифр")
before: dict[str, Any] = {}
after: dict[str, Any] = {}
await self._apply_cells(meta, body, before, after)
if header is not None:
await self._apply_header(document, meta, header, before, after)
if not after:
return await self.build_out(document, meta)
if meta.checks_accepted_at is not None:
before["checks_accepted"] = {"by": meta.checks_accepted_by, "at": meta.checks_accepted_at}
after["checks_accepted"] = None
meta.checks_accepted_by = meta.checks_accepted_at = meta.checks_accepted_comment = None
await self._session.flush()
await self.recompute_status(document, meta)
await self._audit.record(
action=AuditAction.RECOGNIZED_DATA_EDIT,
section=AuditSection.RECOGNITION,
object_type=OBJECT_TYPE,
object_id=document.id,
before_value=before,
after_value=after,
)
return await self.build_out(document, meta)
async def accept_checks(self, document_id: uuid.UUID, body: schemas.ChecksAcceptIn) -> schemas.StatementOut:
"""Акцепт непройденных жёстких проверок (D-8): поля акцепта в шапке, пересчёт статуса, аудит."""
document, meta = await self._editable(document_id)
if not await self.hard_failures(document, meta):
raise ConflictError(NOTHING_TO_ACCEPT, "непройденных жёстких проверок нет")
now = utc_now()
by = self._audit.user_id
meta.checks_accepted_by, meta.checks_accepted_at, meta.checks_accepted_comment = by, now, body.comment
await self._session.flush()
await self.recompute_status(document, meta)
await self._audit.record(
action=AuditAction.RECOGNIZED_DATA_EDIT,
section=AuditSection.RECOGNITION,
object_type=OBJECT_TYPE,
object_id=document.id,
before_value={"checks_accepted": None},
after_value={"checks_accepted": {"by": by, "at": now, "comment": body.comment}},
)
return await self.build_out(document, meta)

View File

@ -3,6 +3,7 @@
import datetime
import json
import uuid
from decimal import Decimal
from http import HTTPStatus
from pathlib import Path
@ -12,9 +13,10 @@ from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.statement_checks import CROSS_FORM_RELATION
from src.models.audit_ex import AuditEvent
from src.models.directories import LegalEntity, RegionalOffice
from src.models.enums import DocumentType, FileStatus, RecognitionStatus
from src.models.files import Document
from src.models.enums import AuditAction, DocumentStatus, DocumentType, FileStatus, RecognitionStatus
from src.models.files import Document, StoredFile
from src.models.statements import (
FinancialStatementColumn,
FinancialStatementLine,
@ -194,14 +196,6 @@ async def test_statement_hidden_is_404(
assert (await client.get(f"/documents/{document.id}/statement")).status_code == HTTPStatus.NOT_FOUND
async def test_cross_form_check(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
balance, _ = await two_forms(seeded, fixtures_dir)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{balance.id}/statement")).json()
cross = body["checks"]["cross_form"]
assert cross is None or cross["relation"] == "1370 − 1370(начало года) = 2400"
async def test_cross_form_violation_is_reported_on_both_forms(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
@ -241,11 +235,246 @@ async def test_cross_form_ignores_superseded_pair(
await set_amount(seeded, balance, "1370", Decimal("500"), current=True)
await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31))
await set_amount(seeded, income, "2400", Decimal("150"), current=True)
from src.models.files import StoredFile # noqa: PLC0415
await seeded.execute(
update(StoredFile).where(StoredFile.id == income.stored_file_id).values(status=FileStatus.SUPERSEDED)
)
await seeded.flush()
client = await auth_client(KA_RF)
assert (await client.get(f"/documents/{balance.id}/statement")).json()["checks"]["cross_form"] is None
async def test_cross_form_pair_without_meta_does_not_shadow(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""T5.1: новее подтверждённый документ того же типа без шапки формы не мешает найти настоящую пару."""
balance, income = await two_forms(seeded, fixtures_dir)
await set_amount(seeded, balance, "1370", Decimal("500"), current=True)
await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31))
await set_amount(seeded, income, "2400", Decimal("150"), current=True)
entity = await seeded.get(LegalEntity, balance.legal_entity_id)
file, job = await make_stored_file(
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
ghost = await make_document(seeded, file=file, job=job, year=2025, quarter=balance.period_quarter)
ghost.document_type = DocumentType.INCOME
ghost.osv_account_code = None
ghost.status = DocumentStatus.CONFIRMED
ghost.created_at = income.created_at + datetime.timedelta(days=1)
await seeded.flush()
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{balance.id}/statement")).json()
assert body["checks"]["cross_form"]["actual"] == "150.00"
async def _first_cell(client, document_id) -> tuple[dict, dict]:
body = (await client.get(f"/documents/{document_id}/statement")).json()
line = next(line for line in body["lines"] if line["values"][0]["amount"] is not None)
return line, line["values"][0]
async def test_patch_value_and_revert(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
original = cell["amount"]
new = str(Decimal(original) + 20)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": new}]},
)
assert response.status_code == HTTPStatus.OK, response.text
edited = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0]
assert Decimal(edited["amount"]) == Decimal(new) and Decimal(edited["original_amount"]) == Decimal(original)
assert edited["is_modified"] is True and edited["modified_by"] is not None
event = (
(
await seeded.execute(
select(AuditEvent).where(
AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT
)
)
)
.scalars()
.one()
)
assert event.before_value and event.after_value
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": original}]},
)
reverted = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0]
assert reverted["is_modified"] is False and Decimal(reverted["original_amount"]) == Decimal(original)
async def test_patch_report_date_recomputes_period(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-06-30"}})
body = response.json()
assert body["document"]["period_quarter"] == "Q2" and body["header"]["report_date"]["modified"] is True
assert body["header"]["report_date"]["original_value"] is not None # исходник не тронут
async def test_patch_inn_validates_and_rechecks(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
bad = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": "12345"}})
assert bad.status_code == HTTPStatus.UNPROCESSABLE_ENTITY and bad.json()["detail"]["code"] == "INVALID_INN"
other = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": "7700000000"}})
assert other.json()["document"]["counterparty_check"] == "MISMATCH"
assert other.json()["document"]["status"] == "NEEDS_CHECK"
async def test_patch_duplicate_cell_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 1."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": "1"}
response = await client.patch(
f"/documents/{document.id}/statement", json={"values": [item, {**item, "amount": "2"}]}
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "DUPLICATE_CELL"
async def test_patch_amount_precision_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 2: больше двух знаков или больше 16 цифр до запятой — 422 схемы, данные не меняются."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
for amount in ("1.234", "12345678901234567"):
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": amount}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, amount
_, after = await _first_cell(client, document.id)
assert after["amount"] == cell["amount"] and after["is_modified"] is False
async def test_patch_unknown_cell(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": str(uuid.uuid4()), "column_id": str(uuid.uuid4()), "amount": "1"}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
async def test_patch_cell_of_other_form_is_unknown(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Ячейка другой формы того же ЮЛ — не ячейка этого документа."""
balance, income = await two_forms(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, income.id)
response = await client.patch(
f"/documents/{balance.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": "1"}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
async def test_patch_requires_edit_right(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(RKA)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}})
assert response.status_code == HTTPStatus.FORBIDDEN
async def test_empty_patch_no_audit(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
assert (await client.patch(f"/documents/{document.id}/statement", json={})).status_code == HTTPStatus.OK
events = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().all()
assert events == []
async def _break_balance(client, document_id) -> None:
body = (await client.get(f"/documents/{document_id}/statement")).json()
line_1600 = next(line for line in body["lines"] if line["line_code"] == "1600")
cell = line_1600["values"][0]
amount = str(Decimal(cell["amount"] or 0) + 1000)
await client.patch(
f"/documents/{document_id}/statement",
json={"values": [{"line_id": line_1600["id"], "column_id": cell["column_id"], "amount": amount}]},
)
async def test_accept_and_reset(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
nothing = await client.post(f"{url}/checks/accept", json={"comment": "ок"})
assert nothing.status_code == HTTPStatus.CONFLICT and nothing.json()["detail"]["code"] == "NOTHING_TO_ACCEPT"
await _break_balance(client, document.id)
broken = (await client.get(url)).json()
assert any(item["relation"].startswith("1600") for item in broken["checks"]["failed"])
assert broken["document"]["status"] == "NEEDS_CHECK"
accepted = await client.post(f"{url}/checks/accept", json={"comment": "округление в форме клиента"})
assert accepted.status_code == HTTPStatus.OK
assert accepted.json()["checks"]["accepted"]["comment"] == "округление в форме клиента"
assert accepted.json()["document"]["status"] == "RECOGNIZED" # других причин NEEDS_CHECK нет (D-6)
await client.patch(url, json={"header": {"units": "руб."}})
after = (await client.get(url)).json()
assert after["checks"]["accepted"] is None and after["document"]["status"] == "NEEDS_CHECK"
async def test_accept_blank_comment_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": " "})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
async def test_patch_confirmed_readonly(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK
url = f"/documents/{document.id}/statement"
response = await client.patch(url, json={"header": {"units": "руб."}})
assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_READONLY"
response = await client.post(f"{url}/checks/accept", json={"comment": "ок"})
assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_READONLY"
async def test_patch_superseded_file(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
await seeded.execute(
update(StoredFile).where(StoredFile.id == document.stored_file_id).values(status=FileStatus.SUPERSEDED)
)
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}})
assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "FILE_SUPERSEDED"
async def test_patch_osv_is_not_statement(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО ОСВ 2")
file, job = await make_stored_file(
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
osv = await make_document(seeded, file=file, job=job)
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"

View File

@ -52,7 +52,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
"/api/v1/documents/{document_id}/entries": {"get"},
"/api/v1/documents/{document_id}/confirm": {"post"},
# форма ББ/ОФР (Срез 3b; PATCH и accept добавит Task 6)
"/api/v1/documents/{document_id}/statement": {"get"},
"/api/v1/documents/{document_id}/statement": {"get", "patch"},
"/api/v1/documents/{document_id}/statement/checks/accept": {"post"},
# consolidations-api (Срез 4)
"/api/v1/consolidations": {"post"},
"/api/v1/company-groups/{group_id}/consolidations": {"get"},