From 2b5e70f7c36abd3ebe4258bc2d776b455747a697 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 11:54:38 +0400 Subject: [PATCH] =?UTF-8?q?feat(statements):=20GET=20/documents/{id}/state?= =?UTF-8?q?ment=20=E2=80=94=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B0,=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B8,=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B8,=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=D0=B8=20=C2=A77,=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20(D-5,=20D-14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Схемы формы, StatementService (видимость, 409 DOCUMENT_NOT_STATEMENT, проверки §7 и межформенная), репозиторий (get_meta, load_form, pair_amounts), маршрут GET с DOCUMENT_VIEW, тесты API и детерминированный тест межформенной проверки. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/api/dependencies/files.py | 13 ++ api/src/api/endpoints/files/__init__.py | 3 +- api/src/api/endpoints/files/statements.py | 35 +++ api/src/repositories/statements.py | 123 ++++++++++- api/src/schemas/statements.py | 226 +++++++++++++++++++ api/src/services/statements.py | 69 ++++++ api/tests/test_api_statements.py | 251 ++++++++++++++++++++++ api/tests/test_openapi_routes.py | 2 + 8 files changed, 719 insertions(+), 3 deletions(-) create mode 100644 api/src/api/endpoints/files/statements.py create mode 100644 api/src/schemas/statements.py create mode 100644 api/src/services/statements.py create mode 100644 api/tests/test_api_statements.py diff --git a/api/src/api/dependencies/files.py b/api/src/api/dependencies/files.py index 5ef5a49..c18f316 100644 --- a/api/src/api/dependencies/files.py +++ b/api/src/api/dependencies/files.py @@ -10,6 +10,7 @@ from src.api.dependencies.directories import SettingsDep, get_scope from src.domain.policy import VisibilityScope from src.services.documents import DocumentService from src.services.primary_files import PrimaryFilesService +from src.services.statements import StatementService def get_primary_files_service( @@ -40,3 +41,15 @@ def get_document_service( DocumentServiceDep = Annotated[DocumentService, Depends(get_document_service)] + + +def get_statement_service( + session: SessionDep, + audit: Annotated[RequestAudit, Depends(get_request_audit)], + scope: Annotated[VisibilityScope, Depends(get_scope)], +) -> StatementService: + """Сервис формы ББ/ОФР: чтение, проверки §7 (видимость — через ЮЛ-владельца).""" + return StatementService(session, audit, scope) + + +StatementServiceDep = Annotated[StatementService, Depends(get_statement_service)] diff --git a/api/src/api/endpoints/files/__init__.py b/api/src/api/endpoints/files/__init__.py index b1b0f9e..d7f946e 100644 --- a/api/src/api/endpoints/files/__init__.py +++ b/api/src/api/endpoints/files/__init__.py @@ -2,9 +2,10 @@ from fastapi import APIRouter -from src.api.endpoints.files import documents, read, upload +from src.api.endpoints.files import documents, read, statements, upload router = APIRouter(tags=["files"]) router.include_router(upload.router) router.include_router(read.router) router.include_router(documents.router) +router.include_router(statements.router) diff --git a/api/src/api/endpoints/files/statements.py b/api/src/api/endpoints/files/statements.py new file mode 100644 index 0000000..b06b4ae --- /dev/null +++ b/api/src/api/endpoints/files/statements.py @@ -0,0 +1,35 @@ +"""`/documents/{document_id}/statement` — форма ББ/ОФР для экрана проверки (spec documents-api «Форма ББ/ОФР для +экрана проверки»; design D-5, D-14). + +Чтение — `DOCUMENT_VIEW`; право проверяется до видимости, документ ЮЛ вне видимости — тот же 404, что несуществующий. +""" + +import uuid +from typing import Any + +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 + +router = APIRouter(prefix="/documents") + +VIEW = [Depends(require("DOCUMENT_VIEW"))] +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`"}, +} + + +@router.get( + "/{document_id}/statement", + response_model=StatementOut, + dependencies=VIEW, + responses=STATEMENT_ERRORS, + summary="Форма ББ/ОФР документа", +) +async def get_statement(document_id: uuid.UUID, service: StatementServiceDep) -> StatementOut: + """Шапка, колонки, строки со значениями, редакция формы и проверки §7 (в т.ч. межформенная).""" + return await service.get(document_id) diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index d3c95f3..1f6a9b2 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -1,6 +1,7 @@ """Таблицы `financial_statement_*` (Срез 3b): вставка формы при маппинге, очистка файла, чтение сумм и флагов значений. Только SQL, без правил: правила — `domain/statement_mapping.py`, `domain/statement_checks.py`.""" +import datetime import uuid from decimal import Decimal from typing import Any @@ -9,7 +10,8 @@ from sqlalchemy import delete, insert, select from sqlalchemy.ext.asyncio import AsyncSession from src.domain.statement_mapping import StatementDraft -from src.models.files import Document +from src.models.enums import DocumentStatus, DocumentType, FileStatus +from src.models.files import Document, StoredFile from src.models.statements import ( FinancialStatementColumn, FinancialStatementLine, @@ -17,7 +19,16 @@ from src.models.statements import ( FinancialStatementValue, ) -__all__ = ["BATCH", "delete_file_statements", "insert_statement", "statement_amounts", "value_flags"] +__all__ = [ + "BATCH", + "delete_file_statements", + "get_meta", + "insert_statement", + "load_form", + "pair_amounts", + "statement_amounts", + "value_flags", +] BATCH = 1000 # значений на один INSERT: 8 колонок × 1000 < 32767 параметров asyncpg @@ -138,3 +149,111 @@ async def value_flags(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[b .where(FinancialStatementLine.meta_id == meta_id) ) return [(bool(modified), recognized, confidence) for modified, recognized, confidence in rows] + + +async def get_meta(session: AsyncSession, document_id: uuid.UUID) -> FinancialStatementMeta | None: + """Шапка формы документа (1:1) или `None`.""" + stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document_id) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def load_form( + session: AsyncSession, meta_id: uuid.UUID +) -> tuple[list[FinancialStatementColumn], list[tuple[FinancialStatementLine, list[FinancialStatementValue]]]]: + """Колонки по `column_index` и строки по `line_code` с их значениями по `column_index`.""" + columns = list( + ( + await session.execute( + select(FinancialStatementColumn) + .where(FinancialStatementColumn.meta_id == meta_id) + .order_by(FinancialStatementColumn.column_index) + ) + ).scalars() + ) + order = {column.id: column.column_index for column in columns} + lines = list( + ( + await session.execute( + select(FinancialStatementLine) + .where(FinancialStatementLine.meta_id == meta_id) + .order_by(FinancialStatementLine.line_code) + ) + ).scalars() + ) + values = ( + await session.execute( + select(FinancialStatementValue).where(FinancialStatementValue.line_id.in_([line.id for line in lines])) + ) + ).scalars() + by_line: dict[uuid.UUID, list[FinancialStatementValue]] = {line.id: [] for line in lines} + for value in values: + by_line[value.line_id].append(value) + for items in by_line.values(): + items.sort(key=lambda value: order[value.column_id]) + return columns, [(line, by_line[line.id]) for line in lines] + + +async def _line_amount( + session: AsyncSession, + meta_id: uuid.UUID, + line_code: str, + *, + current: bool = False, + period_date: datetime.date | None = None, +) -> Decimal | None: + """Сумма строки `line_code` в колонке `is_current` (`current`) или с датой `period_date`; нет — `None`.""" + stmt = ( + select(FinancialStatementValue.amount) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .join(FinancialStatementColumn, FinancialStatementColumn.id == FinancialStatementValue.column_id) + .where(FinancialStatementLine.meta_id == meta_id, FinancialStatementLine.line_code == line_code) + .order_by(FinancialStatementColumn.column_index) + .limit(1) + ) + if current: + stmt = stmt.where(FinancialStatementColumn.is_current.is_(True)) + else: + stmt = stmt.where(FinancialStatementColumn.period_date == period_date) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def _pair_document(session: AsyncSession, document: Document, pair_type: DocumentType) -> Document | None: + """Форма-пара (D-5): другой тип формы того же ЮЛ, года и квартала, файл не `SUPERSEDED`; подтверждённая первой, + затем самая поздняя по `created_at`.""" + stmt = ( + select(Document) + .join(StoredFile, StoredFile.id == Document.stored_file_id) + .where( + Document.legal_entity_id == document.legal_entity_id, + Document.period_year == document.period_year, + Document.period_quarter == document.period_quarter, + Document.document_type == pair_type, + StoredFile.status != FileStatus.SUPERSEDED, + ) + .order_by((Document.status == DocumentStatus.CONFIRMED).desc(), Document.created_at.desc()) + .limit(1) + ) + return (await session.execute(stmt)).scalar_one_or_none() + + +async def pair_amounts( + session: AsyncSession, document: Document +) -> tuple[tuple[Decimal | None, Decimal | None], Decimal | None]: + """Вход межформенной проверки (D-5): `(1370 на отчётную дату, 1370 на 31.12 прошлого года)` из ББ и `2400` ОФР + в колонке отчётного периода. Нет пары, шапки пары, строки или колонки — соответствующий `None`.""" + if document.period_year is None or document.period_quarter is None: + return (None, None), None + is_balance = document.document_type == DocumentType.BALANCE + pair = await _pair_document(session, document, DocumentType.INCOME if is_balance else DocumentType.BALANCE) + own_meta = await get_meta(session, document.id) + pair_meta = await get_meta(session, pair.id) if pair is not None else None + balance_meta, income_meta = (own_meta, pair_meta) if is_balance else (pair_meta, own_meta) + balance: tuple[Decimal | None, Decimal | None] = (None, None) + if balance_meta is not None: + year_start = datetime.date(document.period_year - 1, 12, 31) + balance = ( + await _line_amount(session, balance_meta.id, "1370", current=True), + await _line_amount(session, balance_meta.id, "1370", period_date=year_start), + ) + income = await _line_amount(session, income_meta.id, "2400", current=True) if income_meta is not None else None + return balance, income diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py new file mode 100644 index 0000000..3b3c465 --- /dev/null +++ b/api/src/schemas/statements.py @@ -0,0 +1,226 @@ +"""Схемы формы ББ/ОФР для экрана «Проверка распознавания» (spec documents-api «Форма ББ/ОФР для экрана проверки»; +design D-5, D-14). + +Суммы — `Decimal` (в JSON — строка, без `float`), как `EntryOut`. Проверки §7 в ответе — вычисленные на лету +(`checks.failed` / `soft_failed` / `cross_form`), акцепт — из шапки. +""" + +import datetime +import uuid +from collections.abc import Sequence +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict + +from src.domain.statement_checks import ChecksReport, FailedCheck +from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter +from src.models.files import Document +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) + + +class HeaderFieldOut(BaseModel): + """Поле шапки, доступное для правки: значение, распознанное исходное и признаки проверки.""" + + value: str | None + original_value: str | None + confidence: Decimal | None = None + verified: bool | None + modified: bool | None + + +class StatementHeaderOut(BaseModel): + inn: HeaderFieldOut + report_date: HeaderFieldOut + units: HeaderFieldOut + organization_name: str | None + okud: str | None + okpo: str | None + okopf: str | None + okfs: str | None + address: str | None + + +class ColumnOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + column_index: int + label: str | None + period_date: datetime.date | None + is_current: bool | None + + +class ValueOut(BaseModel): + id: uuid.UUID + column_id: uuid.UUID + column_index: int + amount: Decimal | None + string_value: str | None + confidence: Decimal | None + is_recognized: bool | None + is_modified: bool + original_amount: Decimal | None + modified_by: uuid.UUID | None + modified_at: datetime.datetime | None + + +class LineOut(BaseModel): + id: uuid.UUID + line_code: str | None + line_code_confidence: Decimal | None + line_name: str | None + line_name_confidence: Decimal | None + values: list[ValueOut] + + +class FailedCheckOut(BaseModel): + """Нарушенное соотношение §7; `column_index = -1` — межформенная проверка (колонки нет).""" + + model_config = ConfigDict(from_attributes=True) + + column_index: int + relation: str + expected: Decimal + actual: Decimal + + +class ChecksAcceptedOut(BaseModel): + by: uuid.UUID + at: datetime.datetime + comment: str + + +class ChecksOut(BaseModel): + failed: list[FailedCheckOut] + soft_failed: list[FailedCheckOut] + cross_form: FailedCheckOut | None + accepted: ChecksAcceptedOut | None + + +class StatementDocumentOut(BaseModel): + id: uuid.UUID + document_type: DocumentType + status: DocumentStatus + period_year: int | None + period_quarter: PeriodQuarter | None + reporting_date: datetime.date | None + counterparty_check: DocumentCheckResult | None + locator: str | None + file_id: uuid.UUID + + +class StatementOut(BaseModel): + document: StatementDocumentOut + edition: str | None + header: StatementHeaderOut + columns: list[ColumnOut] + lines: list[LineOut] + checks: ChecksOut + + +def _failed(check: FailedCheck) -> FailedCheckOut: + return FailedCheckOut( + column_index=check.column_index, relation=check.relation, expected=check.expected, actual=check.actual + ) + + +def _value_out(value: FinancialStatementValue, column_index: dict[uuid.UUID, int]) -> ValueOut: + return ValueOut( + id=value.id, + column_id=value.column_id, + column_index=column_index[value.column_id], + amount=value.amount, + string_value=value.string_value, + confidence=value.confidence, + is_recognized=value.is_recognized, + is_modified=value.is_modified, + original_amount=value.original_amount, + modified_by=value.modified_by, + modified_at=value.modified_at, + ) + + +def _accepted(meta: FinancialStatementMeta) -> ChecksAcceptedOut | None: + if meta.checks_accepted_at is None or meta.checks_accepted_by is None: + return None + return ChecksAcceptedOut( + by=meta.checks_accepted_by, at=meta.checks_accepted_at, comment=meta.checks_accepted_comment or "" + ) + + +def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из частей формы + document: Document, + meta: FinancialStatementMeta, + columns: Sequence[FinancialStatementColumn], + lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], + report: ChecksReport, + cross: FailedCheck | None, + *, + edition: str | None, +) -> StatementOut: + """Ответ экрана проверки: документ, шапка, колонки, строки со значениями и проверки.""" + column_index = {column.id: column.column_index for column in columns} + return StatementOut( + document=StatementDocumentOut( + id=document.id, + document_type=document.document_type, + status=document.status, + period_year=document.period_year, + period_quarter=document.period_quarter, + reporting_date=document.reporting_date, + counterparty_check=document.counterparty_check, + locator=document.locator, + file_id=document.stored_file_id, + ), + edition=edition, + header=StatementHeaderOut( + inn=HeaderFieldOut( + value=meta.inn, + original_value=meta.inn_original_value, + confidence=meta.inn_confidence, + verified=meta.inn_verified, + modified=meta.inn_modified, + ), + report_date=HeaderFieldOut( + value=meta.report_date.isoformat() if meta.report_date is not None else None, + original_value=meta.report_date_original_value, + verified=meta.report_date_verified, + modified=meta.report_date_modified, + ), + units=HeaderFieldOut( + value=meta.units, + original_value=meta.units_original_value, + verified=meta.units_verified, + modified=meta.units_modified, + ), + organization_name=meta.organization_name, + okud=meta.okud, + okpo=meta.okpo, + okopf=meta.okopf, + okfs=meta.okfs, + address=meta.address, + ), + columns=[ColumnOut.model_validate(column) for column in columns], + lines=[ + LineOut( + id=line.id, + line_code=line.line_code, + line_code_confidence=line.line_code_confidence, + line_name=line.line_name, + line_name_confidence=line.line_name_confidence, + values=[_value_out(value, column_index) for value in values], + ) + for line, values in lines + ], + checks=ChecksOut( + failed=[_failed(check) for check in report.failed], + soft_failed=[_failed(check) for check in report.soft_failed], + cross_form=_failed(cross) if cross is not None else None, + accepted=_accepted(meta), + ), + ) diff --git a/api/src/services/statements.py b/api/src/services/statements.py new file mode 100644 index 0000000..4c122c6 --- /dev/null +++ b/api/src/services/statements.py @@ -0,0 +1,69 @@ +"""Форма ББ/ОФР для экрана «Проверка распознавания» (spec documents-api; design D-5, D-7, D-8, D-14). + +Видимость — через ЮЛ-владельца (`files_repo.get_document`): чужой документ — тот же 404, что несуществующий. +Проверки §7 считаются на лету и не хранятся. +""" + +import uuid + +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.dependencies.audit import RequestAudit +from src.common.exceptions import ConflictError, NotFoundError +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.models.statements import FinancialStatementMeta +from src.repositories import files as files_repo +from src.repositories import statements as repo +from src.schemas import statements as schemas + +DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего +DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT" +DOCUMENT_READONLY = "DOCUMENT_READONLY" +UNKNOWN_CELL = "UNKNOWN_CELL" +DUPLICATE_CELL = "DUPLICATE_CELL" +NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT" +INVALID_INN = "INVALID_INN" +FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME}) + + +class StatementService: + def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: + self._session = session + self._audit = audit + self._scope = scope + + async def _visible_statement(self, document_id: uuid.UUID) -> tuple[Document, FinancialStatementMeta]: + """Документ формы в пределах видимости: 404 — нет или чужой; 409 `DOCUMENT_NOT_STATEMENT` — не форма.""" + document = await files_repo.get_document(self._session, self._scope, document_id) + if document is None: + raise NotFoundError(DOCUMENT_NOT_FOUND) + meta = await repo.get_meta(self._session, document.id) if document.document_type in FORM_TYPES else None + 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]: + """Проверки §7 по текущим суммам формы и мягкая межформенная проверка (D-5).""" + rules = load_check_rules() + column_indexes, amounts = await repo.statement_amounts(self._session, meta.id) + report = run_checks(document.document_type.value, amounts, column_indexes, rules) + balance_1370, income_2400 = await repo.pair_amounts(self._session, document) + return report, cross_form_check(balance_1370, income_2400, rules.tolerance) + + async def get(self, document_id: uuid.UUID) -> schemas.StatementOut: + document, meta = await self._visible_statement(document_id) + return await self.build_out(document, meta) + + async def build_out(self, document: Document, meta: FinancialStatementMeta) -> schemas.StatementOut: + """Ответ формы по текущему состоянию БД (используется и ответами правки/акцепта).""" + columns, lines = await repo.load_form(self._session, meta.id) + report, cross = await self.checks_report(document, meta) + return schemas.statement_out( + document, meta, columns, lines, report, cross, edition=edition_for(document.reporting_date) + ) diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py new file mode 100644 index 0000000..3295a54 --- /dev/null +++ b/api/tests/test_api_statements.py @@ -0,0 +1,251 @@ +"""API формы ББ/ОФР (spec documents-api «Форма ББ/ОФР для экрана проверки», «Правка формы», «Акцепт проверок формы», +«Подтверждение документа»; statement-checks; design D-5, D-7…D-9, D-14).""" + +import datetime +import json +from decimal import Decimal +from http import HTTPStatus +from pathlib import Path + +import pytest +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.statement_checks import CROSS_FORM_RELATION +from src.models.directories import LegalEntity, RegionalOffice +from src.models.enums import DocumentType, FileStatus, RecognitionStatus +from src.models.files import Document +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) +from tests.conftest import AuthClientFactory +from tests.factories import make_company_group, make_document, make_legal_entity, make_stored_file +from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot + +pytestmark = [pytest.mark.integration, pytest.mark.fixtures] + +KA_RF = "ka_rf@armka.local" +RKA = "rka@armka.local" # только DOCUMENT_VIEW +ROSTOV = "Ростовский региональный филиал" +MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json" +MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" +MEZH_BALANCE_LINES = 37 + + +async def rostov_entity(session: AsyncSession, path: Path, name: str = "ООО Межениновская") -> LegalEntity: + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(session, name=f"ГК {name}", office=office) + data = json.loads(path.read_text("utf-8")) + inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None) + return await make_legal_entity(session, name=name, inn=inn, group=group) + + +async def form(session: AsyncSession, fixtures_dir: Path, path: Path = MEZH_BALANCE) -> Document: + entity = await rostov_entity(session, fixtures_dir / path) + documents = await map_golden_snapshot(session, entity=entity, path=fixtures_dir / path, uploaded_by=None) + return next(d for d in documents if d.document_type in (DocumentType.BALANCE, DocumentType.INCOME)) + + +async def two_forms(session: AsyncSession, fixtures_dir: Path) -> tuple[Document, Document]: + """ББ и ОФР Межениновской за 2025 Q3 одного ЮЛ (два файла).""" + entity = await rostov_entity(session, fixtures_dir / MEZH_BALANCE) + balance = next( + d + for d in await map_golden_snapshot(session, entity=entity, path=fixtures_dir / MEZH_BALANCE, uploaded_by=None) + if d.document_type == DocumentType.BALANCE + ) + income = next( + d + for d in await map_golden_snapshot(session, entity=entity, path=fixtures_dir / MEZH_INCOME, uploaded_by=None) + if d.document_type == DocumentType.INCOME + ) + return balance, income + + +async def set_amount( # noqa: PLR0913 - адресация ячейки: документ, строка, колонка + session: AsyncSession, + document: Document, + line_code: str, + amount: Decimal, + *, + current: bool = False, + period_date: datetime.date | None = None, +) -> None: + """Прямая правка суммы ячейки в БД теста (колонка `is_current` или с датой `period_date`).""" + meta = ( + await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) + ).scalar_one() + column_filter = ( + FinancialStatementColumn.is_current.is_(True) + if current + else FinancialStatementColumn.period_date == period_date + ) + column_id = ( + await session.execute( + select(FinancialStatementColumn.id) + .where(FinancialStatementColumn.meta_id == meta.id, column_filter) + .order_by(FinancialStatementColumn.column_index) + .limit(1) + ) + ).scalar_one() + line_id = ( + await session.execute( + select(FinancialStatementLine.id).where( + FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == line_code + ) + ) + ).scalar_one() + await session.execute( + update(FinancialStatementValue) + .where(FinancialStatementValue.line_id == line_id, FinancialStatementValue.column_id == column_id) + .values(amount=amount) + ) + await session.flush() + + +async def test_get_statement(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + response = await client.get(f"/documents/{document.id}/statement") + assert response.status_code == HTTPStatus.OK, response.text + body = response.json() + assert body["edition"] == "fsbu4" + assert body["document"]["document_type"] == "BALANCE" and body["document"]["period_quarter"] == "Q3" + assert len(body["lines"]) == MEZH_BALANCE_LINES + codes = [line["line_code"] for line in body["lines"]] + assert codes == sorted(codes) + columns = len(body["columns"]) + assert all(len(line["values"]) == columns for line in body["lines"]) + assert body["checks"]["failed"] == [] and body["checks"]["accepted"] is None + assert set(body["header"]["inn"]) == {"value", "original_value", "confidence", "verified", "modified"} + + +async def test_get_statement_shape_and_decimal_strings( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Документ, шапка и значения: поля спеки, суммы — строкой, `column_index` значения совпадает с колонкой.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(RKA) # DOCUMENT_VIEW достаточно + body = (await client.get(f"/documents/{document.id}/statement")).json() + assert set(body["document"]) == { + "id", + "document_type", + "status", + "period_year", + "period_quarter", + "reporting_date", + "counterparty_check", + "locator", + "file_id", + } + assert {"organization_name", "okud", "okpo", "okopf", "okfs", "address"} <= set(body["header"]) + assert set(body["columns"][0]) == {"id", "column_index", "label", "period_date", "is_current"} + line = next(line for line in body["lines"] if any(v["amount"] is not None for v in line["values"])) + assert set(line) >= {"id", "line_code", "line_code_confidence", "line_name", "line_name_confidence", "values"} + value = next(v for v in line["values"] if v["amount"] is not None) + assert isinstance(value["amount"], str) + assert set(value) == { + "id", + "column_id", + "column_index", + "amount", + "string_value", + "confidence", + "is_recognized", + "is_modified", + "original_amount", + "modified_by", + "modified_at", + } + index_by_id = {column["id"]: column["column_index"] for column in body["columns"]} + assert all(index_by_id[v["column_id"]] == v["column_index"] for line in body["lines"] for v in line["values"]) + assert [v["column_index"] for v in line["values"]] == sorted(v["column_index"] for v in line["values"]) + + +async def test_get_income_statement(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + document = await form(seeded, fixtures_dir, MEZH_INCOME) + client = await auth_client(KA_RF) + body = (await client.get(f"/documents/{document.id}/statement")).json() + assert body["document"]["document_type"] == "INCOME" and body["edition"] == "fsbu4" + assert body["lines"] + + +async def test_statement_of_osv_is_conflict( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО ОСВ") + 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.get(f"/documents/{osv.id}/statement") + assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_NOT_STATEMENT" + + +async def test_statement_hidden_is_404( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client("client_manager@armka.local") # другой филиал + 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: + """Детерминированно (D-5): 1370 = 500 на 30.09.2025 и 300 на 31.12.2024, 2400 = 150 → expected 200, actual 150.""" + 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) + client = await auth_client(KA_RF) + for document in (balance, income): + body = (await client.get(f"/documents/{document.id}/statement")).json() + assert body["checks"]["cross_form"] == { + "column_index": -1, + "relation": CROSS_FORM_RELATION, + "expected": "200.00", + "actual": "150.00", + }, document.document_type + # соотношение выполнено: 500 − 300 = 200 → проверка не нарушена + await set_amount(seeded, income, "2400", Decimal("200"), current=True) + body = (await client.get(f"/documents/{balance.id}/statement")).json() + assert body["checks"]["cross_form"] is None + + +async def test_cross_form_without_pair_is_null( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + assert (await client.get(f"/documents/{document.id}/statement")).json()["checks"]["cross_form"] is None + + +async def test_cross_form_ignores_superseded_pair( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Пара из файла `SUPERSEDED` не участвует (D-5).""" + 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) + 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 diff --git a/api/tests/test_openapi_routes.py b/api/tests/test_openapi_routes.py index b55c4f4..1475f9c 100644 --- a/api/tests/test_openapi_routes.py +++ b/api/tests/test_openapi_routes.py @@ -51,6 +51,8 @@ EXPECTED_ROUTES: dict[str, set[str]] = { "/api/v1/documents/{document_id}": {"get"}, "/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"}, # consolidations-api (Срез 4) "/api/v1/consolidations": {"post"}, "/api/v1/company-groups/{group_id}/consolidations": {"get"},