diff --git a/api/src/api/endpoints/files/statements.py b/api/src/api/endpoints/files/statements.py index b06b4ae..a3085b7 100644 --- a/api/src/api/endpoints/files/statements.py +++ b/api/src/api/endpoints/files/statements.py @@ -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) diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 1f6a9b2..2001dca 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -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, diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index 3b3c465..e2d0a56 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -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) diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 4c122c6..ef4defb 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -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) diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 3295a54..80e9f20 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -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" diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index 1475f9c..a834587 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -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"},