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:
parent
729d4cb072
commit
2b5e70f7c3
@ -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)]
|
||||
|
||||
@ -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)
|
||||
|
||||
35
api/src/api/endpoints/files/statements.py
Normal file
35
api/src/api/endpoints/files/statements.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
226
api/src/schemas/statements.py
Normal file
226
api/src/schemas/statements.py
Normal 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),
|
||||
),
|
||||
)
|
||||
69
api/src/services/statements.py
Normal file
69
api/src/services/statements.py
Normal 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)
|
||||
)
|
||||
251
api/tests/test_api_statements.py
Normal file
251
api/tests/test_api_statements.py
Normal 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
|
||||
@ -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"},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user