feat(statements): GET /documents/{id}/statement — шапка, колонки, строки, проверки §7, редакция (D-5, D-14)

Схемы формы, StatementService (видимость, 409 DOCUMENT_NOT_STATEMENT, проверки §7 и межформенная),
репозиторий (get_meta, load_form, pair_amounts), маршрут GET с DOCUMENT_VIEW, тесты API и детерминированный
тест межформенной проверки.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 11:54:38 +04:00
parent 729d4cb072
commit 2b5e70f7c3
8 changed files with 719 additions and 3 deletions

View File

@ -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)]

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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),
),
)

View File

@ -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)
)

View File

@ -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

View File

@ -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"},