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:
parent
2b5e70f7c3
commit
38a6a1b0c2
@ -1,7 +1,8 @@
|
|||||||
"""`/documents/{document_id}/statement` — форма ББ/ОФР для экрана проверки (spec documents-api «Форма ББ/ОФР для
|
"""`/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
|
import uuid
|
||||||
@ -12,15 +13,28 @@ from fastapi import APIRouter, Depends, status
|
|||||||
from src.api.dependencies.auth import require
|
from src.api.dependencies.auth import require
|
||||||
from src.api.dependencies.files import StatementServiceDep
|
from src.api.dependencies.files import StatementServiceDep
|
||||||
from src.schemas.common import ErrorDetail
|
from src.schemas.common import ErrorDetail
|
||||||
from src.schemas.statements import StatementOut
|
from src.schemas.statements import ChecksAcceptIn, StatementOut, StatementPatch
|
||||||
|
|
||||||
router = APIRouter(prefix="/documents")
|
router = APIRouter(prefix="/documents")
|
||||||
|
|
||||||
VIEW = [Depends(require("DOCUMENT_VIEW"))]
|
VIEW = [Depends(require("DOCUMENT_VIEW"))]
|
||||||
|
EDIT = [Depends(require("DOCUMENT_EDIT"))]
|
||||||
STATEMENT_ERRORS: dict[int | str, dict[str, Any]] = {
|
STATEMENT_ERRORS: dict[int | str, dict[str, Any]] = {
|
||||||
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"},
|
status.HTTP_404_NOT_FOUND: {"model": ErrorDetail, "description": "нет или вне видимости"},
|
||||||
status.HTTP_409_CONFLICT: {"model": ErrorDetail, "description": "`DOCUMENT_NOT_STATEMENT`"},
|
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(
|
@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:
|
async def get_statement(document_id: uuid.UUID, service: StatementServiceDep) -> StatementOut:
|
||||||
"""Шапка, колонки, строки со значениями, редакция формы и проверки §7 (в т.ч. межформенная)."""
|
"""Шапка, колонки, строки со значениями, редакция формы и проверки §7 (в т.ч. межформенная)."""
|
||||||
return await service.get(document_id)
|
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)
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import uuid
|
import uuid
|
||||||
|
from collections.abc import Sequence
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -25,6 +26,7 @@ __all__ = [
|
|||||||
"get_meta",
|
"get_meta",
|
||||||
"insert_statement",
|
"insert_statement",
|
||||||
"load_form",
|
"load_form",
|
||||||
|
"lock_cells",
|
||||||
"pair_amounts",
|
"pair_amounts",
|
||||||
"statement_amounts",
|
"statement_amounts",
|
||||||
"value_flags",
|
"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]
|
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:
|
async def get_meta(session: AsyncSession, document_id: uuid.UUID) -> FinancialStatementMeta | None:
|
||||||
"""Шапка формы документа (1:1) или `None`."""
|
"""Шапка формы документа (1:1) или `None`."""
|
||||||
stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document_id)
|
stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document_id)
|
||||||
@ -176,7 +207,7 @@ async def load_form(
|
|||||||
await session.execute(
|
await session.execute(
|
||||||
select(FinancialStatementLine)
|
select(FinancialStatementLine)
|
||||||
.where(FinancialStatementLine.meta_id == meta_id)
|
.where(FinancialStatementLine.meta_id == meta_id)
|
||||||
.order_by(FinancialStatementLine.line_code)
|
.order_by(FinancialStatementLine.line_code, FinancialStatementLine.id)
|
||||||
)
|
)
|
||||||
).scalars()
|
).scalars()
|
||||||
)
|
)
|
||||||
@ -219,10 +250,11 @@ async def _line_amount(
|
|||||||
|
|
||||||
async def _pair_document(session: AsyncSession, document: Document, pair_type: DocumentType) -> Document | None:
|
async def _pair_document(session: AsyncSession, document: Document, pair_type: DocumentType) -> Document | None:
|
||||||
"""Форма-пара (D-5): другой тип формы того же ЮЛ, года и квартала, файл не `SUPERSEDED`; подтверждённая первой,
|
"""Форма-пара (D-5): другой тип формы того же ЮЛ, года и квартала, файл не `SUPERSEDED`; подтверждённая первой,
|
||||||
затем самая поздняя по `created_at`."""
|
затем самая поздняя по `created_at`. Документ без шапки формы не считается парой (иначе он затенял бы настоящую)."""
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Document)
|
select(Document)
|
||||||
.join(StoredFile, StoredFile.id == Document.stored_file_id)
|
.join(StoredFile, StoredFile.id == Document.stored_file_id)
|
||||||
|
.join(FinancialStatementMeta, FinancialStatementMeta.document_id == Document.id)
|
||||||
.where(
|
.where(
|
||||||
Document.legal_entity_id == document.legal_entity_id,
|
Document.legal_entity_id == document.legal_entity_id,
|
||||||
Document.period_year == document.period_year,
|
Document.period_year == document.period_year,
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import uuid
|
|||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from decimal import Decimal
|
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.domain.statement_checks import ChecksReport, FailedCheck
|
||||||
from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter
|
from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter
|
||||||
@ -224,3 +224,36 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч
|
|||||||
accepted=_accepted(meta),
|
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)
|
||||||
|
|||||||
@ -4,21 +4,36 @@
|
|||||||
Проверки §7 считаются на лету и не хранятся.
|
Проверки §7 считаются на лету и не хранятся.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.api.dependencies.audit import RequestAudit
|
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.policy import VisibilityScope
|
||||||
from src.domain.statement_checks import ChecksReport, FailedCheck, cross_form_check, load_check_rules, run_checks
|
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.domain.statement_mapping import edition_for, quarter_of, statement_period, statement_status, values_ok
|
||||||
from src.models.enums import DocumentType
|
from src.models.directories import LegalEntity
|
||||||
from src.models.files import Document
|
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.models.statements import FinancialStatementMeta
|
||||||
from src.repositories import files as files_repo
|
from src.repositories import files as files_repo
|
||||||
from src.repositories import statements as repo
|
from src.repositories import statements as repo
|
||||||
|
from src.repositories.counterparties import CounterpartyIndex
|
||||||
from src.schemas import statements as schemas
|
from src.schemas import statements as schemas
|
||||||
|
from src.services.documents import CONFIRMABLE_STATUSES, DOCUMENT_NOT_CONFIRMABLE, FILE_SUPERSEDED
|
||||||
|
|
||||||
DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего
|
DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего
|
||||||
DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT"
|
DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT"
|
||||||
@ -27,6 +42,8 @@ UNKNOWN_CELL = "UNKNOWN_CELL"
|
|||||||
DUPLICATE_CELL = "DUPLICATE_CELL"
|
DUPLICATE_CELL = "DUPLICATE_CELL"
|
||||||
NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT"
|
NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT"
|
||||||
INVALID_INN = "INVALID_INN"
|
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})
|
FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME})
|
||||||
|
|
||||||
|
|
||||||
@ -46,6 +63,24 @@ class StatementService:
|
|||||||
raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР")
|
raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР")
|
||||||
return document, meta
|
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(
|
async def checks_report(
|
||||||
self, document: Document, meta: FinancialStatementMeta
|
self, document: Document, meta: FinancialStatementMeta
|
||||||
) -> tuple[ChecksReport, FailedCheck | None]:
|
) -> tuple[ChecksReport, FailedCheck | None]:
|
||||||
@ -67,3 +102,135 @@ class StatementService:
|
|||||||
return schemas.statement_out(
|
return schemas.statement_out(
|
||||||
document, meta, columns, lines, report, cross, edition=edition_for(document.reporting_date)
|
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)
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
|
import uuid
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -12,9 +13,10 @@ from sqlalchemy import select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.domain.statement_checks import CROSS_FORM_RELATION
|
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.directories import LegalEntity, RegionalOffice
|
||||||
from src.models.enums import DocumentType, FileStatus, RecognitionStatus
|
from src.models.enums import AuditAction, DocumentStatus, DocumentType, FileStatus, RecognitionStatus
|
||||||
from src.models.files import Document
|
from src.models.files import Document, StoredFile
|
||||||
from src.models.statements import (
|
from src.models.statements import (
|
||||||
FinancialStatementColumn,
|
FinancialStatementColumn,
|
||||||
FinancialStatementLine,
|
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
|
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(
|
async def test_cross_form_violation_is_reported_on_both_forms(
|
||||||
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||||
) -> None:
|
) -> 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("500"), current=True)
|
||||||
await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31))
|
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)
|
await set_amount(seeded, income, "2400", Decimal("150"), current=True)
|
||||||
from src.models.files import StoredFile # noqa: PLC0415
|
|
||||||
|
|
||||||
await seeded.execute(
|
await seeded.execute(
|
||||||
update(StoredFile).where(StoredFile.id == income.stored_file_id).values(status=FileStatus.SUPERSEDED)
|
update(StoredFile).where(StoredFile.id == income.stored_file_id).values(status=FileStatus.SUPERSEDED)
|
||||||
)
|
)
|
||||||
await seeded.flush()
|
await seeded.flush()
|
||||||
client = await auth_client(KA_RF)
|
client = await auth_client(KA_RF)
|
||||||
assert (await client.get(f"/documents/{balance.id}/statement")).json()["checks"]["cross_form"] is None
|
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"
|
||||||
|
|||||||
@ -52,7 +52,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
|
|||||||
"/api/v1/documents/{document_id}/entries": {"get"},
|
"/api/v1/documents/{document_id}/entries": {"get"},
|
||||||
"/api/v1/documents/{document_id}/confirm": {"post"},
|
"/api/v1/documents/{document_id}/confirm": {"post"},
|
||||||
# форма ББ/ОФР (Срез 3b; PATCH и accept добавит Task 6)
|
# форма ББ/ОФР (Срез 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)
|
# consolidations-api (Срез 4)
|
||||||
"/api/v1/consolidations": {"post"},
|
"/api/v1/consolidations": {"post"},
|
||||||
"/api/v1/company-groups/{group_id}/consolidations": {"get"},
|
"/api/v1/company-groups/{group_id}/consolidations": {"get"},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user