arm-core/api/tests/test_api_statements.py
Andrey Epifancev 028512603a fix(statements): правки ревью B1+B2 — T2.1, T1.1, T1.2, T2.4, T2.6, T2.8
T2.1: аудит lines[] включает итоговую смену parent_row_index после пересчёта.
T1.1: тест паритета редакции приёма и пересчёта для строки с confidence < 100 и code_fixed.
T1.2: edition_codes пропускает строку ok без кода.
T2.4: общий issue_column_index для ответа формы и detail отказа подтверждения.
T2.6: удалено дублирование needs_review в statement_line_edits.
T2.8: неизвестный relative:* не даёт 500 (адаптер stored_form и _resolve_relative_columns).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:15:21 +04:00

2565 lines
128 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""API формы ББ/ОФР (spec documents-api «Форма ББ/ОФР для экрана проверки», «Правка формы», «Акцепт проверок формы»,
«Подтверждение документа»; statement-checks; design D-5, D-7…D-9, D-14)."""
import datetime
import json
import uuid
from decimal import Decimal
from http import HTTPStatus
from pathlib import Path
from typing import Any
import pytest
from fastapi import FastAPI
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.dependencies.files import get_statements_engine
from src.domain.statement_checks import CROSS_FORM_RELATION
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent
from src.models.directories import CompanyGroup, LegalEntity, RegionalOffice
from src.models.enums import (
AuditAction,
DocumentIssueKind,
DocumentStatus,
DocumentType,
FileStatus,
IssueSeverity,
IssueSource,
PeriodQuarter,
RecognitionStatus,
StatementLineStatus,
)
from src.models.files import Document, RecognitionJob, StoredFile
from src.models.statements import (
DocumentIssue,
FinancialStatementColumn,
FinancialStatementLine,
FinancialStatementMeta,
FinancialStatementValue,
)
from src.services.statement_recompute import recompute_form
from src.services.statements import form_blocker
from src.services.statements_engine import default_engine
from tests.conftest import AuthClientFactory
from tests.factories import (
make_company_group,
make_consolidation,
make_document,
make_legal_entity,
make_stored_file,
)
from tests.fake_engine import FakeEngine
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"
# ББ с пятизначными строками детализации
UNAGRANDE_BALANCE = GOLDEN_STATEMENTS / "unagrande-group" / "2025-Q3" / "ББ. Лист 1 3кв.25.json"
MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
MEZH_BALANCE_LINES = 37
MEZH_BALANCE_TEMPLATE_ONLY = {"1105", "1215"} # коды fsbu4, которых нет в файле Межениновской (файл по форме 66н)
MEZH_INCOME_TEMPLATE_ONLY = {"2420"}
EVENTS_AFTER_BREAK_AND_ACCEPT = 2
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"
source = [line for line in body["lines"] if line["origin"] == "source"]
template = [line for line in body["lines"] if line["origin"] == "template"]
assert len(source) == MEZH_BALANCE_LINES and all(line["id"] for line in source)
assert {line["line_code"] for line in template} == MEZH_BALANCE_TEMPLATE_ONLY
assert all(line["id"] is None for line in template)
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",
"form_variant",
}
assert {
"organization_name",
"okud",
"okpo",
"okopf",
"okfs",
"address",
"okei",
"needs_review_fields",
"source",
"organization_name_modified",
} <= set(body["header"])
assert set(body["columns"][0]) == {
"id",
"column_index",
"label",
"period_date",
"is_current",
"period_kind",
"label_confidence",
}
line = next(line for line in body["lines"] if any(v["amount"] is not None for v in line["values"]))
assert set(line) == {
"id",
"origin",
"row_index",
"source_ref",
"line_status",
"line_code",
"line_code_text",
"line_code_confidence",
"line_code_modified",
"reference_name",
"parent_row_index",
"needs_review",
"is_confirmed",
"line_name",
"line_name_confidence",
"parent_code",
"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",
"text",
"needs_review",
"is_confirmed",
}
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_statement_parent_code_of_sublines(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""AURORA-1527: у пятизначной подстроки `parent_code` — её четырёхзначная строка, у остальных (в т. ч. шаблонных) —
`null`; родитель тот же, что у проверки «сумма подстрок = родитель»."""
document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
parents = {line["line_code"]: line["parent_code"] for line in body["lines"]}
assert parents["11101"] == "1110" and parents["11201"] == "1120" and parents["11510"] == "1150"
assert parents["11904"] == "1190" and parents["12101"] == "1210"
assert all((parent is not None) == (len(code) == 5) for code, parent in parents.items()) # noqa: PLR2004
assert all(line["parent_code"] is None for line in body["lines"] if line["origin"] == "template")
async def test_get_statement_parent_code_matches_subline_check(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Нарушенная «1110 = 11101 + …» строится по тем же подстрокам, что отдаёт `parent_code`."""
document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE)
await set_amount(seeded, document, "1110", Decimal("999999"), current=True)
await set_amount(seeded, document, "11101", Decimal("1"), current=True) # детализация есть — проверка работает
await recompute_now(seeded, document)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
kids = sorted(line["line_code"] for line in body["lines"] if line["parent_code"] == "1110")
assert kids == ["11101", "11102", "11103"]
assert f"1110 = {' + '.join(kids)}" in {item["relation"] for item in body["checks"]["soft_failed"]}
async def test_get_statement_no_subline_warnings_without_detail(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""AURORA-1527: все подстроки родителя пусты — «1150 = 11501 + …», «1170 = 11701», «1210 = 12101» не выдаются."""
document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
soft = [item["relation"] for item in body["checks"]["soft_failed"]]
assert not any(relation.startswith(("1150 =", "1170 =", "1210 =")) for relation in soft), soft
async def test_get_statement_orphan_subline_has_no_parent(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Подстрока без четырёхзначной строки в форме остаётся без родителя (и не попадает в проверку)."""
document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE)
await drop_line(seeded, document, "1170")
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
parents = {line["line_code"]: line["parent_code"] for line in body["lines"] if line["origin"] == "source"}
assert parents["11701"] is None
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 _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta:
return (
await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id))
).scalar_one()
async def recompute_now(session: AsyncSession, document: Document) -> None:
"""Пересчёт формы общим кодом, как после первой правки: §7 хранится в `document_issue`, а не считается на лету.
До приёма B3 у загруженных маппером форм записей нарушений нет — тест, меняющий суммы в БД напрямую, зовёт это."""
meta = await _meta(session, document)
job = await session.get(RecognitionJob, meta.recognition_job_id)
await recompute_form(session, document=document, meta=meta, engine=default_engine(), job=job)
await session.flush()
async def drop_line(session: AsyncSession, document: Document, line_code: str) -> dict[int, Decimal | None]:
"""Удалить строку формы в БД теста (как будто источник её потерял); вернуть её суммы по `column_index`."""
meta = await _meta(session, document)
line = (
await session.execute(
select(FinancialStatementLine).where(
FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == line_code
)
)
).scalar_one()
rows = (
await session.execute(
select(FinancialStatementColumn.column_index, FinancialStatementValue.amount)
.join(FinancialStatementColumn, FinancialStatementColumn.id == FinancialStatementValue.column_id)
.where(FinancialStatementValue.line_id == line.id)
)
).all()
await session.execute(delete(FinancialStatementValue).where(FinancialStatementValue.line_id == line.id))
await session.execute(delete(FinancialStatementLine).where(FinancialStatementLine.id == line.id))
await session.flush()
return dict(rows)
async def drop_value(session: AsyncSession, document: Document, line_code: str, column_index: int) -> None:
"""Удалить одну ячейку строки в БД теста."""
meta = await _meta(session, document)
line_id = (
await session.execute(
select(FinancialStatementLine.id).where(
FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == line_code
)
)
).scalar_one()
column_id = (
await session.execute(
select(FinancialStatementColumn.id).where(
FinancialStatementColumn.meta_id == meta.id, FinancialStatementColumn.column_index == column_index
)
)
).scalar_one()
await session.execute(
delete(FinancialStatementValue).where(
FinancialStatementValue.line_id == line_id, FinancialStatementValue.column_id == column_id
)
)
await session.flush()
async def db_line_count(session: AsyncSession, document: Document) -> int:
meta = await _meta(session, document)
ids = await session.execute(select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id == meta.id))
return len(ids.all())
async def test_get_income_statement_template_lines(
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()
template = {line["line_code"] for line in body["lines"] if line["origin"] == "template"}
assert template == MEZH_INCOME_TEMPLATE_ONLY
async def test_get_completes_lost_line_without_writing(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Строка, потерянная источником (1700), достроена из справочника; статус и проверки — как без достройки."""
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
await recompute_now(seeded, document)
lines_in_db = await db_line_count(seeded, document)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
line = next(line for line in body["lines"] if line["line_code"] == "1700")
assert line["id"] is None and line["origin"] == "template" and line["line_name"] == "БАЛАНС"
assert line["line_code_confidence"] is None and line["line_name_confidence"] is None
assert len(line["values"]) == len(body["columns"])
assert all(
v["id"] is None
and v["amount"] is None
and v["is_recognized"] is None
and v["is_modified"] is False
and v["original_amount"] is None
and v["confidence"] is None
for v in line["values"]
)
assert [v["column_index"] for v in line["values"]] == sorted(c["column_index"] for c in body["columns"])
relations = {(f["relation"], f["column_index"]) for f in body["checks"]["failed"]}
# пакет (P1-21): посчитанное соотношение с пустой целью — нарушение с `expected = null`; «1600 = 1700» (цель 1600
# есть, 1700 пуста) нарушено — как и без достройки
lost_target = [f for f in body["checks"]["failed"] if f["relation"].startswith("1700 =")]
assert lost_target and all(f["expected"] is None for f in lost_target)
assert {index for relation, index in relations if relation == "1600 = 1700"} == {0, 1, 2}
assert body["document"]["status"] == document.status.value # статус пересчитывается только правкой
assert await db_line_count(seeded, document) == lines_in_db
async def test_get_completes_missing_cell(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_value(seeded, document, "1150", 1)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
line = next(line for line in body["lines"] if line["line_code"] == "1150")
assert line["origin"] == "source" and len(line["values"]) == len(body["columns"])
missing = next(v for v in line["values"] if v["column_index"] == 1)
assert missing["id"] is None and missing["amount"] is None
assert [v["column_index"] for v in line["values"]] == sorted(v["column_index"] for v in line["values"])
async def test_get_without_edition_completes_cells_only(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_value(seeded, document, "1150", 1)
document.reporting_date = None
await seeded.flush()
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
assert body["edition"] is None
assert len(body["lines"]) == MEZH_BALANCE_LINES
assert all(line["origin"] == "source" for line in body["lines"])
assert all(len(line["values"]) == len(body["columns"]) for line in 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_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)
await recompute_now(seeded, balance)
await recompute_now(seeded, income)
client = await auth_client(KA_RF)
for document in (balance, income):
body = (await client.get(f"/documents/{document.id}/statement")).json()
own = "1370" if document is balance else "2400" # line_ids — строки той формы, что читается
own_ids = [line["id"] for line in body["lines"] if line["line_code"] == own and line["id"] is not None]
assert body["checks"]["cross_form"] == {
"column_index": -1,
"relation": CROSS_FORM_RELATION,
"expected": "200.00",
"actual": "150.00",
"codes": ["1370", "2400"],
"line_ids": own_ids,
}, document.document_type
# мягкая межформенная проверка не блокирует подтверждение: подмена 1370 ломает и жёсткие проверки самого ББ —
# после их акцепта форма с нарушением 1370↔2400 подтверждается
failures = (await client.post(f"/documents/{balance.id}/confirm")).json()["detail"]["failures"]
assert failures and all(item["relation"] != CROSS_FORM_RELATION for item in failures)
accepted = await client.post(f"/documents/{balance.id}/statement/checks/accept", json={"comment": "тест"})
assert accepted.status_code == HTTPStatus.OK, accepted.text
assert accepted.json()["checks"]["cross_form"] is not None
confirmed = await client.post(f"/documents/{balance.id}/confirm")
assert confirmed.status_code == HTTPStatus.OK, confirmed.text
assert confirmed.json()["status"] == "CONFIRMED"
# соотношение выполнено: 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)
await seeded.execute(
update(StoredFile).where(StoredFile.id == income.stored_file_id).values(status=FileStatus.SUPERSEDED)
)
await seeded.flush()
client = await auth_client(KA_RF)
assert (await client.get(f"/documents/{balance.id}/statement")).json()["checks"]["cross_form"] is None
async def test_cross_form_pair_without_meta_does_not_shadow(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""T5.1: новее подтверждённый документ того же типа без шапки формы не мешает найти настоящую пару."""
balance, income = await two_forms(seeded, fixtures_dir)
await set_amount(seeded, balance, "1370", Decimal("500"), current=True)
await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31))
await set_amount(seeded, income, "2400", Decimal("150"), current=True)
entity = await seeded.get(LegalEntity, balance.legal_entity_id)
file, job = await make_stored_file(
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
ghost = await make_document(seeded, file=file, job=job, year=2025, quarter=balance.period_quarter)
ghost.document_type = DocumentType.INCOME
ghost.osv_account_code = None
ghost.status = DocumentStatus.CONFIRMED
ghost.created_at = income.created_at + datetime.timedelta(days=1)
await seeded.flush()
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{balance.id}/statement")).json()
assert body["checks"]["cross_form"]["actual"] == "150.00"
async def _first_cell(client, document_id) -> tuple[dict, dict]:
body = (await client.get(f"/documents/{document_id}/statement")).json()
line = next(line for line in body["lines"] if line["values"][0]["amount"] is not None)
return line, line["values"][0]
async def test_patch_value_and_revert(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
original = cell["amount"]
new = str(Decimal(original) + 20)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": new}]},
)
assert response.status_code == HTTPStatus.OK, response.text
edited = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0]
assert Decimal(edited["amount"]) == Decimal(new) and Decimal(edited["original_amount"]) == Decimal(original)
assert edited["is_modified"] is True and edited["modified_by"] is not None
event = (
(
await seeded.execute(
select(AuditEvent).where(
AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT
)
)
)
.scalars()
.one()
)
key = f"value:{edited['id']}"
assert set(event.before_value) == {key} == set(event.after_value)
assert Decimal(event.before_value[key]) == Decimal(original) and Decimal(event.after_value[key]) == Decimal(new)
user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one()
assert edited["modified_by"] == str(user_id)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": original}]},
)
reverted = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0]
assert reverted["is_modified"] is False and Decimal(reverted["original_amount"]) == Decimal(original)
async def test_patch_report_date_recomputes_period(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
original = (await client.get(url)).json()["header"]["report_date"]["original_value"]
assert original is not None
response = await client.patch(url, json={"header": {"report_date": "2025-06-30"}})
body = response.json()
assert body["document"]["period_quarter"] == "Q2" and body["header"]["report_date"]["modified"] is True
assert body["header"]["report_date"]["value"] == "2025-06-30"
assert body["header"]["report_date"]["original_value"] == original # исходник не тронут
async def test_patch_report_date_not_quarter_end(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 5 (PATCH): дата не на конце квартала — квартал пуст, статус `NEEDS_CHECK`."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-10-31"}})
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()["document"]
assert body["period_quarter"] is None and body["status"] == "NEEDS_CHECK"
async def test_patch_inn_validates_and_rechecks(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
bad = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": "12345"}})
assert bad.status_code == HTTPStatus.UNPROCESSABLE_ENTITY and bad.json()["detail"]["code"] == "INVALID_INN"
other = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": "7700000000"}})
assert other.json()["document"]["counterparty_check"] == "MISMATCH"
assert other.json()["document"]["status"] == "NEEDS_CHECK"
async def test_patch_inn_null_is_invalid_and_keeps_inn(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""M-5: `inn: null` — 422 `INVALID_INN` (ИНН не очищается, MISMATCH не обходится), данные не меняются."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
before = (await client.get(url)).json()
response = await client.patch(url, json={"header": {"inn": None}})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "INVALID_INN"
assert (await client.get(url)).json() == before
assert (await client.patch(url, json={"header": {"units": "тыс. руб."}})).status_code == HTTPStatus.OK # без inn
async def test_patch_invalid_inn_changes_nothing(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""После `INVALID_INN` значения из того же тела не применяются, аудита нет."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
line, cell = await _first_cell(client, document.id)
before = (await client.get(url)).json()
body = {
"header": {"inn": "12345"},
"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)}],
}
response = await client.patch(url, json=body)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "INVALID_INN"
assert (await client.get(url)).json() == before
events = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().all()
assert events == []
async def test_patch_rereads_meta_under_lock(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Шапка перечитывается под блокировкой: акцепт, поставленный параллельным запросом после загрузки шапки в
сессию, виден правке и сбрасывается ею (иначе акцепт остался бы поверх изменённых данных)."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
line, cell = await _first_cell(client, document.id)
# карта идентичности слабая: удерживаем шапку, как это делает запрос между `_visible_statement` и блокировкой
held = (
await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id))
).scalar_one()
user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one()
await seeded.execute(
update(FinancialStatementMeta)
.where(FinancialStatementMeta.document_id == document.id)
.values(
checks_accepted_by=user_id,
checks_accepted_at=datetime.datetime.now(datetime.UTC),
checks_accepted_comment="x",
)
.execution_options(synchronize_session=False) # объект в сессии остаётся устаревшим, как у ждущего запроса
)
item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)}
response = await client.patch(url, json={"values": [item]})
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["checks"]["accepted"] is None
await seeded.refresh(held)
assert held.checks_accepted_at is None and held.checks_accepted_comment is None
event = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().one()
assert event.before_value["checks_accepted"]["by"] == str(user_id) and event.after_value["checks_accepted"] is None
assert event.before_value["checks_accepted"]["comment"] == "x" # T6.4: сброшенный комментарий акцепта сохранён
async def test_patch_duplicate_cell_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 1."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": "1"}
response = await client.patch(
f"/documents/{document.id}/statement", json={"values": [item, {**item, "amount": "2"}]}
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "DUPLICATE_CELL"
async def test_patch_amount_precision_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 2: больше двух знаков или больше 16 цифр до запятой — 422 схемы, данные не меняются."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
for amount in ("1.234", "12345678901234567"):
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": amount}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, amount
_, after = await _first_cell(client, document.id)
assert after["amount"] == cell["amount"] and after["is_modified"] is False
async def test_patch_unknown_cell(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": str(uuid.uuid4()), "column_id": str(uuid.uuid4()), "amount": "1"}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
async def test_patch_cell_of_other_form_is_unknown(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Ячейка другой формы того же ЮЛ — не ячейка этого документа."""
balance, income = await two_forms(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, income.id)
response = await client.patch(
f"/documents/{balance.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": "1"}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
async def test_patch_requires_edit_right(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(RKA)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}})
assert response.status_code == HTTPStatus.FORBIDDEN
async def test_empty_patch_no_audit(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
assert (await client.patch(f"/documents/{document.id}/statement", json={})).status_code == HTTPStatus.OK
events = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().all()
assert events == []
async def _break_balance(client, document_id) -> None:
body = (await client.get(f"/documents/{document_id}/statement")).json()
line_1600 = next(line for line in body["lines"] if line["line_code"] == "1600")
cell = line_1600["values"][0]
amount = str(Decimal(cell["amount"] or 0) + 1000)
await client.patch(
f"/documents/{document_id}/statement",
json={"values": [{"line_id": line_1600["id"], "column_id": cell["column_id"], "amount": amount}]},
)
async def test_1x_form_with_violation_not_confirmable(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, tmp_path: Path
) -> None:
"""Форма `1.x` с нарушением §7 с приёма: `NEEDS_CHECK`, `POST …/confirm` без акцепта — 409 `CHECKS_NOT_PASSED`."""
data = json.loads((fixtures_dir / MEZH_BALANCE).read_text("utf-8"))
for line in data["documents"][0]["lines"]:
if line["line_code"] == "1600":
line["values"][0]["amount"] = str(Decimal(line["values"][0]["amount"] or 0) + 1000)
path = tmp_path / "violation.json"
path.write_text(json.dumps(data, ensure_ascii=False), "utf-8")
entity = await rostov_entity(seeded, path)
(document,) = await map_golden_snapshot(seeded, entity=entity, path=path, uploaded_by=None)
assert document.status is DocumentStatus.NEEDS_CHECK
client = await auth_client(KA_RF)
blocked = await client.post(f"/documents/{document.id}/confirm")
assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED"
failed = (await _statement(client, document))["checks"]["failed"]
assert any(item["codes"] == ["1600", "1700"] for item in failed)
async def test_accept_and_reset(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
nothing = await client.post(f"{url}/checks/accept", json={"comment": "ок"})
assert nothing.status_code == HTTPStatus.CONFLICT and nothing.json()["detail"]["code"] == "NOTHING_TO_ACCEPT"
await _break_balance(client, document.id)
broken = (await client.get(url)).json()
assert any(item["relation"].startswith("1600") for item in broken["checks"]["failed"])
assert broken["document"]["status"] == "NEEDS_CHECK"
accepted = await client.post(f"{url}/checks/accept", json={"comment": "округление в форме клиента"})
assert accepted.status_code == HTTPStatus.OK
assert accepted.json()["checks"]["accepted"]["comment"] == "округление в форме клиента"
assert accepted.json()["document"]["status"] == "RECOGNIZED" # других причин NEEDS_CHECK нет (D-6)
await client.patch(url, json={"header": {"units": "руб."}})
after = (await client.get(url)).json()
assert after["checks"]["accepted"] is None and after["document"]["status"] == "NEEDS_CHECK"
async def test_accept_audit(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
await _break_balance(client, document.id)
response = await client.post(
f"/documents/{document.id}/statement/checks/accept", json={"comment": " округление "}
)
assert response.status_code == HTTPStatus.OK, response.text
user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one()
events = (
(
await seeded.execute(
select(AuditEvent).where(
AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT
)
)
)
.scalars()
.all()
)
assert len(events) == EVENTS_AFTER_BREAK_AND_ACCEPT # правка значения в `_break_balance` и акцепт
(accept,) = [event for event in events if "checks_accepted" in event.after_value]
assert accept.before_value == {"checks_accepted": None}
assert set(accept.after_value) == {"checks_accepted"}
assert set(accept.after_value["checks_accepted"]) == {"by", "at", "comment"}
assert accept.after_value["checks_accepted"]["by"] == str(user_id)
assert accept.after_value["checks_accepted"]["comment"] == "округление" # без пробелов по краям
async def test_value_patch_resets_accept(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Сбрасывает акцепт и правка одного только значения (не только шапки)."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
await _break_balance(client, document.id)
assert (await client.post(f"{url}/checks/accept", json={"comment": "ок"})).status_code == HTTPStatus.OK
line, cell = await _first_cell(client, document.id)
item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)}
response = await client.patch(url, json={"values": [item]})
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["checks"]["accepted"] is None
async def test_accept_blank_comment_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": " "})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
async def test_patch_confirmed_readonly(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK
url = f"/documents/{document.id}/statement"
response = await client.patch(url, json={"header": {"units": "руб."}})
assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_READONLY"
response = await client.post(f"{url}/checks/accept", json={"comment": "ок"})
assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_READONLY"
async def test_patch_superseded_file(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
await seeded.execute(
update(StoredFile).where(StoredFile.id == document.stored_file_id).values(status=FileStatus.SUPERSEDED)
)
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}})
assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "FILE_SUPERSEDED"
async def test_patch_osv_is_not_statement(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО ОСВ 2")
file, job = await make_stored_file(
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
osv = await make_document(seeded, file=file, job=job)
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{osv.id}/statement", json={"header": {"units": "руб."}})
assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_NOT_STATEMENT"
async def test_confirm_requires_checks(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
await _break_balance(client, document.id)
blocked = await client.post(f"/documents/{document.id}/confirm")
assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED"
failures = blocked.json()["detail"]["failures"]
assert failures and set(failures[0]) == {"column_index", "relation", "expected", "actual"}
assert all(isinstance(failure["expected"], str) and isinstance(failure["actual"], str) for failure in failures)
status = await seeded.scalar(select(Document.status).where(Document.id == document.id))
assert status is not DocumentStatus.CONFIRMED
await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "округление"})
confirmed = await client.post(f"/documents/{document.id}/confirm")
assert confirmed.status_code == HTTPStatus.OK and confirmed.json()["status"] == "CONFIRMED"
meta = (
await seeded.execute(
select(FinancialStatementMeta)
.where(FinancialStatementMeta.document_id == document.id)
.execution_options(populate_existing=True)
)
).scalar_one()
assert meta.reviewed_by is not None and meta.reviewed_at is not None
async def _meta_of(session: AsyncSession, document: Document) -> FinancialStatementMeta:
return (
await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id))
).scalar_one()
async def _add_unresolved( # noqa: PLR0913 - фабрика тестовой строки
session: AsyncSession,
document: Document,
*,
status: StatementLineStatus,
row_index: int,
code_text: str | None = None,
amount: Decimal | None = None,
string_value: str | None = None,
) -> FinancialStatementLine:
meta = await _meta_of(session, document)
line = FinancialStatementLine(meta_id=meta.id, line_status=status, row_index=row_index, line_code_text=code_text)
session.add(line)
await session.flush()
column = (
await session.execute(
select(FinancialStatementColumn).where(FinancialStatementColumn.meta_id == meta.id).limit(1)
)
).scalar_one()
session.add(FinancialStatementValue(line_id=line.id, column_id=column.id, amount=amount, string_value=string_value))
await session.flush()
return line
async def _add_confirmed_twin(session: AsyncSession, document: Document) -> Document:
twin = Document(
stored_file_id=document.stored_file_id,
legal_entity_id=document.legal_entity_id,
document_type=document.document_type,
file_ordinal=document.file_ordinal + 1,
period_year=document.period_year,
period_quarter=document.period_quarter,
status=DocumentStatus.CONFIRMED,
created_at=document.created_at,
updated_at=document.updated_at,
)
session.add(twin)
await session.flush()
return twin
async def test_confirm_unresolved_line_with_amount(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
await _break_balance(client, document.id) # жёсткое нарушение не мешает: LINES_UNRESOLVED раньше
line = await _add_unresolved(seeded, document, status=StatementLineStatus.missing, row_index=40, amount=Decimal(7))
blocked = await client.post(f"/documents/{document.id}/confirm")
assert blocked.status_code == HTTPStatus.CONFLICT
detail = blocked.json()["detail"]
assert detail["code"] == "LINES_UNRESOLVED"
assert detail["lines"] == [{"line_id": str(line.id), "row_index": 40, "line_code_text": None}]
async def test_confirm_unresolved_line_with_text_only(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
await _add_unresolved(
seeded,
document,
status=StatementLineStatus.unreadable,
row_index=41,
code_text="ИЧ,250",
string_value="211(1 |",
)
blocked = await client.post(f"/documents/{document.id}/confirm")
assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "LINES_UNRESOLVED"
async def test_unresolved_line_without_values_does_not_block(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
await _add_unresolved(seeded, document, status=StatementLineStatus.unreadable, row_index=41)
confirmed = await client.post(f"/documents/{document.id}/confirm")
assert confirmed.status_code == HTTPStatus.OK and confirmed.json()["status"] == "CONFIRMED"
async def test_confirm_duplicate_form(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
"""Review Focus 1: вторая форма того же типа и периода в файле после подтверждения первой — `DUPLICATE_FORM`."""
first = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
first_confirmed = await client.post(f"/documents/{first.id}/confirm")
assert first_confirmed.status_code == HTTPStatus.OK
second = Document(
stored_file_id=first.stored_file_id,
legal_entity_id=first.legal_entity_id,
document_type=first.document_type,
file_ordinal=first.file_ordinal + 1,
period_year=first.period_year,
period_quarter=first.period_quarter,
status=DocumentStatus.NEEDS_CHECK,
created_at=first.created_at,
updated_at=first.updated_at,
)
seeded.add(second)
await seeded.flush()
first_meta = await _meta_of(seeded, first)
seeded.add(
FinancialStatementMeta(
document_id=second.id,
recognition_job_id=first_meta.recognition_job_id,
statement_type=first_meta.statement_type,
)
)
await seeded.flush()
blocked = await client.post(f"/documents/{second.id}/confirm")
assert blocked.status_code == HTTPStatus.CONFLICT
assert blocked.json()["detail"]["code"] == "DUPLICATE_FORM"
assert blocked.json()["detail"]["document_id"] == str(first.id)
async def test_conflict_order(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
"""PERIOD_REQUIRED -> LINES_UNRESOLVED -> DUPLICATE_FORM -> CHECKS_NOT_PASSED."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
await _break_balance(client, document.id)
await _add_confirmed_twin(seeded, document)
line = await _add_unresolved(seeded, document, status=StatementLineStatus.missing, row_index=40, amount=Decimal(7))
quarter = document.period_quarter
document.period_quarter = None
await seeded.flush()
async def code() -> str:
return (await client.post(f"/documents/{document.id}/confirm")).json()["detail"]["code"]
seen = [await code()]
document.period_quarter = quarter
await seeded.flush()
seen.append(await code())
await seeded.execute(delete(FinancialStatementValue).where(FinancialStatementValue.line_id == line.id))
await seeded.flush()
seen.append(await code())
await seeded.execute(delete(Document).where(Document.status == DocumentStatus.CONFIRMED))
await seeded.flush()
seen.append(await code())
assert seen == ["PERIOD_REQUIRED", "LINES_UNRESOLVED", "DUPLICATE_FORM", "CHECKS_NOT_PASSED"]
async def test_confirm_after_non_quarter_date_edit(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 5: дата не конец квартала → квартал NULL, NEEDS_CHECK, PERIOD_REQUIRED; исправление даты снимает."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
edit = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-10-31"}})
body = edit.json()
assert body["document"]["period_quarter"] is None and body["document"]["status"] == "NEEDS_CHECK"
blocked = await client.post(f"/documents/{document.id}/confirm")
assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "PERIOD_REQUIRED"
await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-09-30"}})
assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK
async def test_period_required_precedes_checks_not_passed(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Порядок D-9: при пустом периоде и нарушенной проверке — `PERIOD_REQUIRED`."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
await _break_balance(client, document.id)
await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-10-31"}})
blocked = await client.post(f"/documents/{document.id}/confirm")
assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "PERIOD_REQUIRED"
async def test_confirm_form_rereads_accept_under_lock(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Акцепт, поставленный параллельным запросом после загрузки шапки в сессию, виден подтверждению (T6.I1)."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
await _break_balance(client, document.id)
held = ( # карта идентичности слабая: удерживаем шапку без акцепта, как это делает ждущий запрос
await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id))
).scalar_one()
assert held.checks_accepted_at is None
user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one()
await seeded.execute(
update(FinancialStatementMeta)
.where(FinancialStatementMeta.document_id == document.id)
.values(
checks_accepted_by=user_id,
checks_accepted_at=datetime.datetime.now(datetime.UTC),
checks_accepted_comment="x",
)
.execution_options(synchronize_session=False) # объект в сессии остаётся устаревшим
)
confirmed = await client.post(f"/documents/{document.id}/confirm")
assert confirmed.status_code == HTTPStatus.OK, confirmed.text
async def test_confirmed_form_in_checklist(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
entity = await seeded.get(LegalEntity, document.legal_entity_id)
assert entity is not None
group = await seeded.get(CompanyGroup, entity.company_group_id)
assert group is not None
user = (await seeded.execute(select(AppUser).where(AppUser.email == KA_RF))).scalar_one()
_, version = await make_consolidation(
seeded, group=group, year=2025, quarter=PeriodQuarter.Q3, members=[entity], initiator=user
)
client = await auth_client(KA_RF)
assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK
checklist = (await client.get(f"/consolidations/versions/{version.id}/checklist")).json()
balance_index = next(i for i, rule in enumerate(checklist["rules"]) if rule["document_type"] == "BALANCE")
row = next(row for row in checklist["rows"] if row["legal_entity_id"] == str(entity.id))
assert row["cells"][balance_index]["status"] == "CONFIRMED"
meta = (
await seeded.execute(
select(FinancialStatementMeta)
.where(FinancialStatementMeta.document_id == document.id)
.execution_options(populate_existing=True)
)
).scalar_one()
assert meta.reviewed_by == user.id
@pytest.mark.parametrize("units", [" ", "", "я" * 51])
async def test_patch_units_invalid_is_422(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, units: str
) -> None:
"""T6.2: пробельная, пустая и длиннее 50 символов единицы измерения отклоняются; `null` — «не менять»."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
response = await client.patch(url, json={"header": {"units": units}})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, response.text
unchanged = await client.patch(url, json={"header": {"units": None}})
assert unchanged.status_code == HTTPStatus.OK
async def test_patch_units_are_stripped(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": " тыс. руб. "}})
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["header"]["units"]["value"] == "тыс. руб."
async def undated_form(session: AsyncSession, fixtures_dir: Path, tmp_path: Path) -> Document:
"""Форма без дат (снимок `st03_no_codes` без `report_date`; колонок в нём нет): период не определяется."""
source = fixtures_dir / GOLDEN_STATEMENTS / "synthetic" / "st03_no_codes.json"
data = json.loads(source.read_text("utf-8"))
for item in data["documents"]:
item["header"]["report_date"] = item["header"]["report_date_text"] = None
path = tmp_path / "st03_undated.json"
path.write_text(json.dumps(data, ensure_ascii=False), "utf-8")
entity = await rostov_entity(session, path, name="ООО Без дат")
(document,) = await map_golden_snapshot(session, entity=entity, path=path, uploaded_by=None)
return document
async def test_edition_66n_for_date_up_to_2024_12_31(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""D-14: редакция 66н — по дате формы ≤ 31.12.2024 (правка даты отчёта меняет редакцию и в ответе, и в чтении)."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
assert (await client.get(url)).json()["edition"] == "fsbu4"
edited = await client.patch(url, json={"header": {"report_date": "2024-12-31"}})
assert edited.status_code == HTTPStatus.OK, edited.text
assert edited.json()["edition"] == "66n" and edited.json()["document"]["period_quarter"] == "Q4"
assert (await client.get(url)).json()["edition"] == "66n"
await client.patch(url, json={"header": {"report_date": "2025-03-31"}})
assert (await client.get(url)).json()["edition"] == "fsbu4" # граница: первый день после 31.12.2024 уже ФСБУ 4
async def test_edition_null_without_dates(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, tmp_path: Path
) -> None:
document = await undated_form(seeded, fixtures_dir, tmp_path)
assert document.reporting_date is None and document.period_year is None
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"] is None and body["document"]["period_quarter"] is None
assert body["checks"]["cross_form"] is None
async def test_cross_form_null_when_quarter_not_defined(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Документ без квартала: межформенная проверка не выполняется (без пары по периоду) и не ломает чтение."""
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)
url = f"/documents/{balance.id}/statement"
assert (await client.get(url)).json()["checks"]["cross_form"] is not None # контроль: пара и нарушение есть
edited = await client.patch(url, json={"header": {"report_date": "2025-10-31"}})
assert edited.status_code == HTTPStatus.OK, edited.text
assert edited.json()["document"]["period_quarter"] is None and edited.json()["checks"]["cross_form"] is None
response = await client.get(url)
assert response.status_code == HTTPStatus.OK and response.json()["checks"]["cross_form"] is None
async def test_cross_form_prefers_confirmed_pair_over_newer(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""D-5: подтверждённая форма-пара выигрывает у более новой неподтверждённой; без подтверждения — самая новая."""
balance, old_income = await two_forms(seeded, fixtures_dir)
entity = await seeded.get(LegalEntity, balance.legal_entity_id)
assert entity is not None
newer_income = next(
d
for d in await map_golden_snapshot(seeded, entity=entity, path=fixtures_dir / MEZH_INCOME, uploaded_by=None)
if d.document_type == DocumentType.INCOME
)
# повторная загрузка заменяет прежний файл; возвращаем его в обработанные — оба претендуют на роль пары
await seeded.execute(
update(StoredFile).where(StoredFile.id == old_income.stored_file_id).values(status=FileStatus.PROCESSED)
)
assert newer_income.created_at > old_income.created_at
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, old_income, "2400", Decimal("150"), current=True) # нарушает 500 − 300 = 200
await set_amount(seeded, newer_income, "2400", Decimal("200"), current=True) # выполняет
client = await auth_client(KA_RF)
url = f"/documents/{balance.id}/statement"
assert (await client.get(url)).json()["checks"]["cross_form"] is None # без подтверждения — более новая пара
await seeded.execute(update(Document).where(Document.id == old_income.id).values(status=DocumentStatus.CONFIRMED))
cross = (await client.get(url)).json()["checks"]["cross_form"]
assert cross is not None and cross["actual"] == "150.00" # подтверждённая пара, хотя она старее
async def test_statement_requires_document_view(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Роль без права `DOCUMENT_VIEW` (ADMIN) получает 403 на чтение формы, правку и акцепт."""
document = await form(seeded, fixtures_dir)
client = await auth_client("admin@armka.local")
url = f"/documents/{document.id}/statement"
assert (await client.get(url)).status_code == HTTPStatus.FORBIDDEN
assert (await client.patch(url, json={"header": {"units": "руб."}})).status_code == HTTPStatus.FORBIDDEN
assert (await client.post(f"{url}/checks/accept", json={"comment": "ок"})).status_code == HTTPStatus.FORBIDDEN
async def test_confirm_form_without_meta_has_no_conditions(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Решение №16: документ типа BALANCE без шапки формы (старые данные) подтверждается без условий D-9."""
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
)
document = await make_document(seeded, file=file, job=job, status=DocumentStatus.NEEDS_CHECK)
document.document_type, document.osv_account_code = DocumentType.BALANCE, None
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{document.id}/confirm")
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["status"] == "CONFIRMED"
async def test_confirm_undated_form_requires_period(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, tmp_path: Path
) -> None:
"""Форма без дат (`period_year` NULL): подтверждение — 409 `PERIOD_REQUIRED`; после правки даты — проходит."""
document = await undated_form(seeded, fixtures_dir, tmp_path)
assert document.period_year is None
client = await auth_client(KA_RF)
blocked = await client.post(f"/documents/{document.id}/confirm")
assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "PERIOD_REQUIRED"
edit = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2024-12-31"}})
assert edit.status_code == HTTPStatus.OK, edit.text
assert edit.json()["document"]["period_year"] == 2024 # noqa: PLR2004 - год даты из правки
assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK
async def test_status_with_lines_but_no_columns_is_stable_after_edit(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, tmp_path: Path
) -> None:
"""M-2: «есть строки» — наличие строк формы (как при маппинге), а не наличие сумм: форма со строками без колонок
остаётся `RECOGNIZED` и после правки шапки."""
data = json.loads((fixtures_dir / MEZH_BALANCE).read_text("utf-8"))
data["documents"][0]["columns"] = []
for line in data["documents"][0]["lines"]:
line["values"] = []
path = tmp_path / "no_columns.json"
path.write_text(json.dumps(data, ensure_ascii=False), "utf-8")
entity = await rostov_entity(seeded, path)
(document,) = await map_golden_snapshot(seeded, entity=entity, path=path, uploaded_by=None)
assert document.status is DocumentStatus.RECOGNIZED
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}})
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["lines"] and response.json()["document"]["status"] == "RECOGNIZED"
async def _column(client, document_id, index: int) -> str:
body = (await client.get(f"/documents/{document_id}/statement")).json()
return next(c["id"] for c in body["columns"] if c["column_index"] == index)
async def _db_line(session: AsyncSession, document: Document, code: str) -> FinancialStatementLine | None:
meta = await _meta(session, document)
return (
await session.execute(
select(FinancialStatementLine)
.where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == code)
.execution_options(populate_existing=True)
)
).scalar_one_or_none()
async def _edit_events(session: AsyncSession, document: Document) -> list[AuditEvent]:
return list(
(
await session.execute(
select(AuditEvent).where(
AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT
)
)
).scalars()
)
async def test_patch_creates_template_line(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": "1700", "column_id": col0, "amount": "2371333"}]},
)
assert response.status_code == HTTPStatus.OK, response.text
line = next(item for item in response.json()["lines"] if item["line_code"] == "1700")
assert line["id"] is not None and line["origin"] == "template" and line["line_name"] == "БАЛАНС"
cell = next(v for v in line["values"] if v["column_id"] == col0)
assert Decimal(cell["amount"]) == Decimal("2371333") and cell["original_amount"] is None
assert cell["is_modified"] is True and cell["is_recognized"] is None and cell["modified_by"] is not None
other = [v for v in line["values"] if v["column_id"] != col0]
assert other and all(v["id"] is None for v in other) # остальные колонки не созданы
db_line = await _db_line(seeded, document, "1700")
assert db_line is not None and db_line.line_code_original_value is None and db_line.line_name_original_value is None
(event,) = await _edit_events(seeded, document)
key = f"value:{cell['id']}"
assert event.before_value == {key: None} and Decimal(event.after_value[key]) == Decimal("2371333")
async def test_patch_two_cells_of_new_line_create_one_line(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 2."""
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
before = await db_line_count(seeded, document)
client = await auth_client(KA_RF)
col0, col1 = await _column(client, document.id, 0), await _column(client, document.id, 1)
response = await client.patch(
f"/documents/{document.id}/statement",
json={
"values": [
{"line_code": "1700", "column_id": col0, "amount": "1"},
{"line_code": "1700", "column_id": col1, "amount": "2"},
]
},
)
assert response.status_code == HTTPStatus.OK, response.text
assert await db_line_count(seeded, document) == before + 1
line = next(item for item in response.json()["lines"] if item["line_code"] == "1700")
assert sum(1 for v in line["values"] if v["id"] is not None) == 2 # noqa: PLR2004
async def test_patch_repeat_line_code_after_creation_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 3: второй «Сохранить» со старым черновиком по коду — 422, строка не задвоена."""
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
item = {"line_code": "1700", "column_id": col0, "amount": "1"}
assert (
await client.patch(f"/documents/{document.id}/statement", json={"values": [item]})
).status_code == HTTPStatus.OK
count = await db_line_count(seeded, document)
response = await client.patch(f"/documents/{document.id}/statement", json={"values": [{**item, "amount": "5"}]})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
assert "line_id" in response.json()["detail"]["message"]
assert await db_line_count(seeded, document) == count
async def test_patch_line_code_existing_or_unknown_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
count = await db_line_count(seeded, document)
for code in ("1150", "1106"): # 1150 есть в форме; 1106 нет в справочнике fsbu4
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": code, "column_id": col0, "amount": "1"}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, code
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
assert await db_line_count(seeded, document) == count
assert await _edit_events(seeded, document) == []
async def test_patch_line_code_without_edition_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
document.reporting_date = None
await seeded.flush()
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": "1700", "column_id": col0, "amount": "1"}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
async def test_patch_null_into_missing_cell_is_noop(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
count = await db_line_count(seeded, document)
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": "1700", "column_id": col0, "amount": None}]},
)
assert response.status_code == HTTPStatus.OK, response.text
assert await db_line_count(seeded, document) == count
assert await _edit_events(seeded, document) == []
async def test_patch_creates_missing_cell_of_existing_line(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_value(seeded, document, "1150", 1)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
line = next(item for item in body["lines"] if item["line_code"] == "1150")
col1 = next(c["id"] for c in body["columns"] if c["column_index"] == 1)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": col1, "amount": "77"}]},
)
assert response.status_code == HTTPStatus.OK, response.text
cell = next(
v
for v in next(i for i in response.json()["lines"] if i["id"] == line["id"])["values"]
if v["column_id"] == col1
)
assert cell["id"] is not None and Decimal(cell["amount"]) == Decimal("77") and cell["original_amount"] is None
assert cell["is_modified"] is True
async def test_patch_bad_column_rolls_back_new_line(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 1: одна ячейка корректна (новая строка), другая — чужая колонка: 422, строка не создана."""
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
count = await db_line_count(seeded, document)
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
response = await client.patch(
f"/documents/{document.id}/statement",
json={
"values": [
{"line_code": "1700", "column_id": col0, "amount": "1"},
{"line_code": "1700", "column_id": str(uuid.uuid4()), "amount": "2"},
]
},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
assert await db_line_count(seeded, document) == count
async def test_patch_cell_address_must_be_exactly_one(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
for item in (
{"line_id": line["id"], "line_code": "1700", "column_id": cell["column_id"], "amount": "1"},
{"column_id": cell["column_id"], "amount": "1"},
):
response = await client.patch(f"/documents/{document.id}/statement", json={"values": [item]})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert isinstance(response.json()["detail"], list) # 422 валидации тела — массив
async def test_patch_duplicate_line_code_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await drop_line(seeded, document, "1700")
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
item = {"line_code": "1700", "column_id": col0, "amount": "1"}
response = await client.patch(
f"/documents/{document.id}/statement", json={"values": [item, {**item, "amount": "2"}]}
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "DUPLICATE_CELL"
async def test_template_line_checks_follow_filled_amounts(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 4 (spec statement-checks): заполненная в одной колонке строка проверяется по всем колонкам,
очищенная — снова не участвует."""
document = await form(seeded, fixtures_dir)
sums = await drop_line(seeded, document, "1700")
assert sums[1] not in (None, Decimal(0)) and sums[2] not in (None, Decimal(0)) # колонки 1, 2 непустые
client = await auth_client(KA_RF)
col0 = await _column(client, document.id, 0)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": "1700", "column_id": col0, "amount": str(sums[0])}]},
)
failed = response.json()["checks"]["failed"]
relation = "1700 = 1300 + 1400 + 1500"
assert sorted(f["column_index"] for f in failed if f["relation"] == relation) == [1, 2]
assert all(f["column_index"] != 0 for f in failed)
response = await client.patch(
f"/documents/{document.id}/statement",
json={
"values": [
{
"line_id": next(i["id"] for i in response.json()["lines"] if i["line_code"] == "1700"),
"column_id": col0,
"amount": None,
}
]
},
)
assert response.status_code == HTTPStatus.OK, response.text
after_clear = response.json()["checks"]["failed"]
# пакет (P1-21): пустая цель при непустых членах — нарушение с `expected = null` (прежнее «очищенная не цель»
# не действует): 1700 пуста во всех колонках — нарушение в каждой
emptied = [f for f in after_clear if f["relation"] == relation]
assert sorted((f["column_index"], f["expected"]) for f in emptied) == [(0, None), (1, None), (2, None)]
assert {f["column_index"] for f in after_clear if f["relation"] == "1600 = 1700"} == {0, 1, 2}
# --- агрегат формы по коду (v5.19, A7) ---
async def insert_line( # noqa: PLR0913 - строка формы теста: код, статус, индекс, сумма
session: AsyncSession,
document: Document,
*,
code: str | None,
row_index: int | None,
status: str = "ok",
amount: Decimal | None = None,
current: bool = True,
code_original: str | None = None,
code_text: str | None = None,
) -> FinancialStatementLine:
"""Строка формы напрямую в БД; ячейка с `amount` — в колонке отчётной даты (`current`) или нулевой."""
meta = await _meta(session, document)
columns = (
(
await session.execute(
select(FinancialStatementColumn)
.where(FinancialStatementColumn.meta_id == meta.id)
.order_by(FinancialStatementColumn.column_index)
)
)
.scalars()
.all()
)
column = next(c for c in columns if c.is_current) if current else columns[0]
line = FinancialStatementLine(
id=uuid.uuid4(),
meta_id=meta.id,
line_code=code,
line_code_original_value=code_original,
line_status=StatementLineStatus(status),
line_code_text=code_text,
line_name="тест",
row_index=row_index,
)
session.add(line)
await session.flush()
if amount is not None:
session.add(
FinancialStatementValue(
id=uuid.uuid4(), line_id=line.id, column_id=column.id, amount=amount, is_modified=False
)
)
await session.flush()
return line
async def test_cross_form_uses_aggregate(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 2: две строки 1370 в колонке отчётной даты — 500 + 300 = 800."""
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("0"), period_date=datetime.date(2024, 12, 31))
await insert_line(seeded, balance, code="1370", row_index=99, amount=Decimal("300"), code_original="1370")
client = await auth_client(KA_RF)
await set_amount(seeded, income, "2400", Decimal("800"), current=True)
assert (await client.get(f"/documents/{income.id}/statement")).json()["checks"]["cross_form"] is None
await set_amount(seeded, income, "2400", Decimal("500"), current=True)
cross = (await client.get(f"/documents/{income.id}/statement")).json()["checks"]["cross_form"]
assert cross is not None and cross["expected"] == "800.00" and cross["actual"] == "500.00"
async def test_parent_code_with_repeated_parent(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE)
await insert_line(seeded, document, code="1150", row_index=98, amount=Decimal("1"), code_original="1150")
await insert_line(seeded, document, code="11501", row_index=99, amount=Decimal("1"), code_original="11501")
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
sub = [line for line in body["lines"] if line["line_code"] == "11501"]
assert [line["parent_code"] for line in sub] == ["1150", "1150"][: len(sub)] and sub
assert len([line for line in body["lines"] if line["line_code"] == "1150"]) == 2 # noqa: PLR2004
async def test_template_not_completed_for_code_with_ok_line(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await insert_line(seeded, document, code="1215", row_index=90, amount=Decimal("5"), code_original="1215")
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
lines = [line for line in body["lines"] if line["line_code"] == "1215"]
assert [(line["origin"], line["id"] is None) for line in lines] == [("source", False)]
async def test_template_completed_for_code_only_unreadable(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
bad = await insert_line(
seeded, document, code=None, row_index=91, status="unreadable", amount=Decimal("5"), code_text="ИЧ,215"
)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
template = [line for line in body["lines"] if line["line_code"] == "1215"]
assert [(line["origin"], line["id"]) for line in template] == [("template", None)]
assert any(line["id"] == str(bad.id) for line in body["lines"])
async def test_patch_by_line_code_with_ok_line_is_unknown_cell(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await insert_line(seeded, document, code="1215", row_index=90, amount=Decimal("5"), code_original="1215")
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
column_id = body["columns"][0]["id"]
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": "1215", "column_id": column_id, "amount": "1"}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
async def test_origin_by_row_index(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
line = await insert_line(seeded, document, code="1250", row_index=7, amount=Decimal("5"), code_original=None)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
assert next(item for item in body["lines"] if item["id"] == str(line.id))["origin"] == "source"
# --- v5.19: поля ответа, порядок строк, issues, справочник строк -----------------------------------------------------
async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta:
return (
await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id))
).scalar_one()
async def _statement(client: Any, document: Document) -> dict[str, Any]:
response = await client.get(f"/documents/{document.id}/statement")
assert response.status_code == HTTPStatus.OK, response.text
return response.json()
async def test_get_statement_v5_19_fields_on_1x_form(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
body = await _statement(await auth_client(KA_RF), document)
source = [line for line in body["lines"] if line["origin"] == "source"]
assert all(line["line_status"] == "ok" and line["source_ref"] is None for line in source)
assert sorted(line["row_index"] for line in source) == list(range(len(source)))
assert all(not line["needs_review"] and not line["is_confirmed"] for line in body["lines"])
assert all(
line["row_index"] is None and line["line_status"] == "ok" for line in body["lines"] if line["id"] is None
)
cells = [value for line in source for value in line["values"] if value["id"] is not None]
assert all(value["text"] == value["string_value"] for value in cells)
assert body["document"]["form_variant"] == "full" # Р-30: вариант определяется при приёме 1.x
assert body["header"]["needs_review_fields"] == [] and body["header"]["organization_name_modified"] is False
assert {column["period_kind"] for column in body["columns"]} <= {"absolute", "none"}
assert body["issues"] == []
async def test_reference_name_first_name(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
body = await _statement(await auth_client(KA_RF), document)
assert body["edition"] == "fsbu4"
line = next(line for line in body["lines"] if line["line_code"] == "1520")
assert line["reference_name"] == "Кредиторская задолженность"
async def test_issues_in_statement_and_document(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
seeded.add(
DocumentIssue(
document_id=document.id,
kind=DocumentIssueKind.warning,
code="COLUMN_SKIPPED",
severity=IssueSeverity.info,
message="колонка пропущена",
column_id=None,
column_index=5,
source=IssueSource.service,
created_at=datetime.datetime(2026, 10, 18, tzinfo=datetime.UTC),
)
)
await seeded.flush()
client = await auth_client(KA_RF)
expected = {
"kind": "warning",
"code": "COLUMN_SKIPPED",
"severity": "info",
"message": "колонка пропущена",
"line_ids": [],
"column_id": None,
"column_index": 5,
"field": None,
"source": "service",
"relation": None,
"codes": [],
"expected": None,
"actual": None,
}
[issue] = (await _statement(client, document))["issues"]
assert {key: value for key, value in issue.items() if key != "id"} == expected
card = (await client.get(f"/documents/{document.id}")).json()
assert [{key: value for key, value in item.items() if key != "id"} for item in card["issues"]] == [expected]
assert card["issue_counts"] == {"error": 0, "info": 1}
async def test_line_code_confidence_and_okei(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
meta = await _meta(seeded, document)
meta.okei = "384"
line = (
await seeded.execute(
select(FinancialStatementLine).where(
FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "1210"
)
)
).scalar_one()
line.line_code_confidence = Decimal("67.50")
await seeded.flush()
body = await _statement(await auth_client(KA_RF), document)
assert body["header"]["okei"] == "384"
assert next(item for item in body["lines"] if item["line_code"] == "1210")["line_code_confidence"] == "67.50"
async def test_header_source_confidence_is_decimal_string(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
meta = await _meta(seeded, document)
meta.header_source = {
"inn": {"text": "x", "confidence": 61.538464},
"units": {"text": "тыс. руб.", "confidence": 100},
"okpo": {"text": None, "confidence": None},
}
meta.needs_review_fields = ["inn"]
await seeded.flush()
header = (await _statement(await auth_client(KA_RF), document))["header"]
assert header["source"]["inn"] == {"text": "x", "confidence": "61.54"}
assert header["source"]["units"]["confidence"] == "100.00"
assert header["source"]["okpo"] == {"text": None, "confidence": None}
assert header["needs_review_fields"] == ["inn"]
async def test_edition_lines(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
body = await _statement(client, document)
codes = [item["code"] for item in body["edition_lines"]]
assert len(codes) == len(set(codes)) and "1120" not in codes and "1215" in codes
assert {item["code"]: item["name"] for item in body["edition_lines"]}["1520"] == "Кредиторская задолженность"
document.reporting_date = None # форма без даты — редакции нет
await seeded.flush()
codes = [item["code"] for item in (await _statement(client, document))["edition_lines"]]
assert len(codes) == len(set(codes)) and "1120" in codes and "1215" in codes
async def test_failed_check_has_codes_and_line_ids(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await set_amount(seeded, document, "1600", Decimal("1"), current=True)
await recompute_now(seeded, document)
body = await _statement(await auth_client(KA_RF), document)
failed = body["checks"]["failed"]
assert failed, body["checks"]
by_code = {line["line_code"]: line["id"] for line in body["lines"] if line["line_status"] == "ok"}
for item in failed:
assert "1600" in item["codes"] or item["codes"]
assert item["line_ids"] and {by_code[code] for code in item["codes"] if code in by_code} >= set(
item["line_ids"]
)
async def test_lines_order_follows_layout(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Строки `ok` — по коду; нечитаемая строка источника идёт сразу за предшествующей строкой `ok`."""
document = await form(seeded, fixtures_dir)
meta = await _meta(seeded, document)
anchor = (
await seeded.execute(
select(FinancialStatementLine).where(
FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "1210"
)
)
).scalar_one()
seeded.add(
FinancialStatementLine(
meta_id=meta.id,
line_code=None,
row_index=(anchor.row_index or 0) * 1000 + 1,
line_status=StatementLineStatus.unreadable,
line_code_text="12?0",
)
)
await seeded.flush()
await seeded.execute(
update(FinancialStatementLine)
.where(
FinancialStatementLine.meta_id == meta.id,
FinancialStatementLine.line_status == StatementLineStatus.unreadable,
)
.values(row_index=(anchor.row_index or 0) * 1000 + 1)
)
lines = (await _statement(await auth_client(KA_RF), document))["lines"]
ok_codes = [line["line_code"] for line in lines if line["line_status"] == "ok"]
assert ok_codes == sorted(ok_codes)
unreadable = next(i for i, line in enumerate(lines) if line["line_status"] == "unreadable")
assert unreadable > 0
assert lines[unreadable]["line_code_text"] == "12?0"
# --- A12: PATCH — подтверждение ячейки, дата колонки, наименование организации, подтверждение даты ---
NOW = datetime.datetime(2026, 10, 18, tzinfo=datetime.UTC)
async def _user_id(session: AsyncSession) -> uuid.UUID:
return (await session.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one()
async def _accept(session: AsyncSession, document: Document) -> None:
"""Акцепт проверок в БД теста (как будто аналитик акцептовал нарушение)."""
meta = await _meta(session, document)
meta.checks_accepted_by, meta.checks_accepted_at = await _user_id(session), NOW
meta.checks_accepted_comment = "ок"
await session.flush()
async def _add_issue(
session: AsyncSession,
document: Document,
code: str,
*,
column_id: uuid.UUID | None = None,
field: str | None = None,
) -> None:
session.add(
DocumentIssue(
document_id=document.id,
kind=DocumentIssueKind.warning,
code=code,
severity=IssueSeverity.info,
message=code,
column_id=column_id,
field=field,
source=IssueSource.service,
created_at=NOW,
)
)
await session.flush()
async def _issue_codes(session: AsyncSession, document: Document) -> list[str]:
stmt = select(DocumentIssue.code).where(DocumentIssue.document_id == document.id).order_by(DocumentIssue.code)
return list((await session.execute(stmt)).scalars())
async def _db_value(session: AsyncSession, line_id: str, column_id: str) -> FinancialStatementValue:
return (
await session.execute(
select(FinancialStatementValue)
.where(
FinancialStatementValue.line_id == uuid.UUID(line_id),
FinancialStatementValue.column_id == uuid.UUID(column_id),
)
.execution_options(populate_existing=True)
)
).scalar_one()
async def _fresh_meta(session: AsyncSession, document: Document) -> FinancialStatementMeta:
return (
await session.execute(
select(FinancialStatementMeta)
.where(FinancialStatementMeta.document_id == document.id)
.execution_options(populate_existing=True)
)
).scalar_one()
async def _db_columns(session: AsyncSession, document: Document) -> list[FinancialStatementColumn]:
meta = await _meta(session, document)
return list(
(
await session.execute(
select(FinancialStatementColumn)
.where(FinancialStatementColumn.meta_id == meta.id)
.order_by(FinancialStatementColumn.column_index)
.execution_options(populate_existing=True)
)
).scalars()
)
async def _undate_columns(session: AsyncSession, document: Document) -> list[FinancialStatementColumn]:
"""Форма без дат: колонки без даты, дата отчёта пуста, период документа не определён."""
columns = await _db_columns(session, document)
for column in columns:
column.period_date, column.period_kind, column.is_current = None, "none", None
meta = await _meta(session, document)
meta.report_date = None
document.reporting_date = document.period_year = document.period_quarter = None
await session.flush()
return columns
async def test_confirm_cell_without_change(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
value = await _db_value(seeded, line["id"], cell["column_id"])
value.needs_review = True
await _accept(seeded, document)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "confirmed": True}]},
)
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()
edited = next(item for item in body["lines"] if item["id"] == line["id"])["values"][0]
assert edited["is_confirmed"] is True and edited["is_modified"] is False and edited["needs_review"] is False
assert edited["amount"] == cell["amount"]
value = await _db_value(seeded, line["id"], cell["column_id"])
assert value.confirmed_by == await _user_id(seeded) and value.confirmed_at is not None
assert body["checks"]["accepted"] is not None
assert len(await _edit_events(seeded, document)) == 1
async def test_confirm_cell_twice_is_noop(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
item = {"line_id": line["id"], "column_id": cell["column_id"], "confirmed": True}
for _ in range(2):
assert (
await client.patch(f"/documents/{document.id}/statement", json={"values": [item]})
).status_code == HTTPStatus.OK
assert len(await _edit_events(seeded, document)) == 1
async def test_unconfirm_cell(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
value = await _db_value(seeded, line["id"], cell["column_id"])
value.confidence, value.is_confirmed, value.needs_review = Decimal(80), True, False
value.confirmed_by, value.confirmed_at = await _user_id(seeded), NOW
await seeded.flush()
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "confirmed": False}]},
)
assert response.status_code == HTTPStatus.OK, response.text
edited = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0]
# ячейка покрыта сошедшимся соотношением — пакет считает её чистой и после снятия подтверждения
assert edited["is_confirmed"] is False and edited["needs_review"] is False
value = await _db_value(seeded, line["id"], cell["column_id"])
assert value.confirmed_by is None and value.confirmed_at is None
assert len(await _edit_events(seeded, document)) == 1
async def test_unconfirm_uncovered_cell(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Сценарий «Снятие подтверждения»: ячейка вне соотношений после снятия подтверждения — `needs_review`."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
db_line = await seeded.get(FinancialStatementLine, uuid.UUID(line["id"]))
assert db_line is not None
db_line.line_code = "1999" # кода нет ни в одном соотношении
value = await _db_value(seeded, line["id"], cell["column_id"])
value.confidence, value.is_confirmed, value.needs_review = Decimal(80), True, False
value.confirmed_by, value.confirmed_at = await _user_id(seeded), NOW
await seeded.flush()
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "confirmed": False}]},
)
assert response.status_code == HTTPStatus.OK, response.text
edited = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0]
assert edited["is_confirmed"] is False and edited["needs_review"] is True
async def test_cell_amount_and_confirmed_is_422(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
address = {"line_id": line["id"], "column_id": cell["column_id"]}
for item in ({**address, "amount": "1", "confirmed": True}, {**address}, {**address, "confirmed": None}):
response = await client.patch(f"/documents/{document.id}/statement", json={"values": [item]})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, item
assert isinstance(response.json()["detail"], list)
async def test_cell_confirmed_by_line_code_is_422(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
_, cell = await _first_cell(client, document.id)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_code": "1105", "column_id": cell["column_id"], "confirmed": True}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert isinstance(response.json()["detail"], list)
async def test_confirm_missing_cell_unknown(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
await seeded.execute(
delete(FinancialStatementValue).where(
FinancialStatementValue.line_id == uuid.UUID(line["id"]),
FinancialStatementValue.column_id == uuid.UUID(cell["column_id"]),
)
)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "confirmed": True}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
assert await _edit_events(seeded, document) == []
async def test_patch_organization_name(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО Ромашка")
documents = await map_golden_snapshot(seeded, entity=entity, path=fixtures_dir / MEZH_BALANCE, uploaded_by=None)
document = next(d for d in documents if d.document_type == DocumentType.BALANCE)
meta = await _meta(seeded, document)
meta.inn, meta.organization_name, meta.organization_name_original_value = None, "прочее", "прочее"
document.counterparty_check = None
await _add_issue(seeded, document, "NO_ORGANIZATION_NO_INN")
await _add_issue(seeded, document, "SEGMENT_COLUMNS_MISMATCH")
await _accept(seeded, document)
client = await auth_client(KA_RF)
response = await client.patch(
f"/documents/{document.id}/statement", json={"header": {"organization_name": " ООО Ромашка "}}
)
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()
assert body["header"]["organization_name"] == "ООО Ромашка"
assert body["header"]["organization_name_modified"] is True
assert body["document"]["counterparty_check"] == "MATCH"
assert body["checks"]["accepted"] is None
meta = await _fresh_meta(seeded, document)
assert meta.organization_name_original_value == "прочее"
assert await _issue_codes(seeded, document) == ["SEGMENT_COLUMNS_MISMATCH"]
[event] = await _edit_events(seeded, document)
assert event.before_value["organization_name"] == "прочее"
assert event.after_value["organization_name"] == "ООО Ромашка"
async def test_patch_organization_name_invalid_is_422(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
for name in ("", " ", "я" * 1001, None): # M-11: null, как inn: null, не молчаливый no-op
response = await client.patch(
f"/documents/{document.id}/statement", json={"header": {"organization_name": name}}
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, name
assert isinstance(response.json()["detail"], list)
async def test_confirm_report_date(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
meta = await _meta(seeded, document)
meta.needs_review_fields = ["report_date"]
await _add_issue(seeded, document, "REPORT_DATE_UNCERTAIN", field="report_date")
await _accept(seeded, document)
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
current = (await client.get(url)).json()["header"]["report_date"]["value"]
assert current is not None
response = await client.patch(url, json={"header": {"report_date": current}})
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()
assert body["header"]["needs_review_fields"] == [] and body["checks"]["accepted"] is not None
assert body["header"]["report_date"]["modified"] is not True
assert await _issue_codes(seeded, document) == []
assert len(await _edit_events(seeded, document)) == 1
again = await client.patch(url, json={"header": {"report_date": current}}) # подтверждать уже нечего
assert again.status_code == HTTPStatus.OK
assert len(await _edit_events(seeded, document)) == 1
async def test_report_date_change_clears_warnings(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
for code in ("PERIOD_UNKNOWN", "REPORT_DATE_UNPARSED", "REPORT_DATE_UNCERTAIN", "SEGMENT_COLUMNS_MISMATCH"):
await _add_issue(seeded, document, code)
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-06-30"}})
assert response.status_code == HTTPStatus.OK, response.text
assert await _issue_codes(seeded, document) == ["SEGMENT_COLUMNS_MISMATCH"]
async def test_inn_change_clears_no_organization(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
meta = await _meta(seeded, document)
inn = meta.inn
meta.inn = None
await _add_issue(seeded, document, "NO_ORGANIZATION_NO_INN")
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": inn}})
assert response.status_code == HTTPStatus.OK, response.text
assert await _issue_codes(seeded, document) == []
async def test_column_date(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
columns = await _undate_columns(seeded, document)
await _add_issue(seeded, document, "COLUMN_WITHOUT_DATE", column_id=columns[0].id)
await _add_issue(seeded, document, "COLUMN_WITHOUT_DATE", column_id=columns[1].id)
await _accept(seeded, document)
client = await auth_client(KA_RF)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"columns": [{"column_id": str(columns[0].id), "period_date": "2024-12-31"}]},
)
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()
column = next(c for c in body["columns"] if c["id"] == str(columns[0].id))
assert column["period_date"] == "2024-12-31" and column["period_kind"] == "absolute"
assert column["is_current"] is True
assert body["document"]["reporting_date"] == "2024-12-31" and body["document"]["period_quarter"] == "Q4"
assert body["checks"]["accepted"] is None
[issue] = (await _statement(client, document))["issues"]
assert issue["column_id"] == str(columns[1].id) # предупреждение другой колонки осталось
[event] = await _edit_events(seeded, document)
assert event.after_value
async def test_column_date_null_is_422(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
column = await _column(client, document.id, 0)
for item in ({"column_id": column, "period_date": None}, {"column_id": column}):
response = await client.patch(f"/documents/{document.id}/statement", json={"columns": [item]})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, item
assert isinstance(response.json()["detail"], list)
async def test_unknown_column_is_422(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
other = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE)
client = await auth_client(KA_RF)
foreign = await _column(client, other.id, 0)
mine = await _column(client, document.id, 0)
for column in (str(uuid.uuid4()), foreign):
response = await client.patch(
f"/documents/{document.id}/statement",
json={"columns": [{"column_id": column, "period_date": "2024-12-31"}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_COLUMN"
duplicate = await client.patch(
f"/documents/{document.id}/statement",
json={
"columns": [
{"column_id": mine, "period_date": "2024-12-31"},
{"column_id": mine, "period_date": "2024-06-30"},
]
},
)
assert duplicate.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert duplicate.json()["detail"]["code"] == "DUPLICATE_COLUMN"
assert await _edit_events(seeded, document) == []
async def test_confirm_column_date(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
column = (await _db_columns(seeded, document))[0]
column.period_kind = "relative:prev_year_end"
await _add_issue(seeded, document, "COLUMN_DATE_UNCERTAIN", column_id=column.id)
await _accept(seeded, document)
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
item = {"column_id": str(column.id), "period_date": column.period_date.isoformat()}
response = await client.patch(url, json={"columns": [item]})
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()
assert next(c for c in body["columns"] if c["id"] == str(column.id))["period_kind"] == "absolute"
assert body["checks"]["accepted"] is not None and body["issues"] == []
assert len(await _edit_events(seeded, document)) == 1
again = await client.patch(url, json={"columns": [item]}) # без предупреждения — без событий
assert again.status_code == HTTPStatus.OK
assert len(await _edit_events(seeded, document)) == 1
async def test_relative_column_becomes_absolute(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
column = (await _db_columns(seeded, document))[1]
column.period_kind, column.period_date = "relative:prev_year_end", datetime.date(2024, 12, 30)
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"columns": [{"column_id": str(column.id), "period_date": "2024-12-31"}]},
)
assert response.status_code == HTTPStatus.OK, response.text
changed = next(c for c in response.json()["columns"] if c["id"] == str(column.id))
assert changed["period_kind"] == "absolute" and changed["period_date"] == "2024-12-31"
async def test_segment_warning_stays_after_value_edit(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
await _add_issue(seeded, document, "SEGMENT_COLUMNS_MISMATCH")
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)}
response = await client.patch(f"/documents/{document.id}/statement", json={"values": [item]})
assert response.status_code == HTTPStatus.OK, response.text
assert await _issue_codes(seeded, document) == ["SEGMENT_COLUMNS_MISMATCH"]
revert = {**item, "amount": cell["amount"]}
assert (
await client.patch(f"/documents/{document.id}/statement", json={"values": [revert]})
).status_code == HTTPStatus.OK
confirm = await client.post(f"/documents/{document.id}/confirm")
assert confirm.status_code == HTTPStatus.OK, confirm.text
# --- B2: пересчёт общим кодом, §7 из document_issue --------------------------------------------------------------
async def _issues(session: AsyncSession, document: Document) -> list[DocumentIssue]:
stmt = (
select(DocumentIssue).where(DocumentIssue.document_id == document.id).execution_options(populate_existing=True)
)
return list((await session.execute(stmt)).scalars())
async def _cell(client: Any, document: Document, code: str, column_index: int) -> tuple[str, str, str | None]:
"""`(line_id, column_id, amount)` ячейки строки `ok` с кодом `code` в колонке `column_index`."""
body = await _statement(client, document)
line = next(item for item in body["lines"] if item["line_code"] == code and item["id"] is not None)
value = next(v for v in line["values"] if v["column_index"] == column_index)
return line["id"], value["column_id"], value["amount"]
async def test_1x_form_after_patch_recomputed(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Сценарий «1.x-документ после PATCH»: запись `relation` по 1200 заменена, статус пересчитан, версии нет."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line_id, column_id, original = await _cell(client, document, "1230", 0)
assert original is not None
await set_amount(seeded, document, "1230", Decimal(original) + 1000, current=True)
await recompute_now(seeded, document) # до B3 запись приёма вставляется пересчётом
broken = [i for i in await _issues(seeded, document) if (i.relation or "").startswith("1200 =")]
assert broken and all(i.kind is DocumentIssueKind.relation and i.source is IssueSource.core for i in broken)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line_id, "column_id": column_id, "amount": original}]},
)
assert response.status_code == HTTPStatus.OK, response.text
assert not [i for i in await _issues(seeded, document) if (i.relation or "").startswith("1200 =")]
assert not any(f["relation"].startswith("1200 =") for f in response.json()["checks"]["failed"])
assert "STATEMENTS_VERSION_MISMATCH" not in await _issue_codes(seeded, document)
async def test_migrated_form_shows_violation_after_first_patch(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Сценарий «Миграция на непустой базе»: форма без записей §7 нарушений не показывает и не блокирует
подтверждение; после первой правки появляется `RELATION_FAILED`."""
document = await form(seeded, fixtures_dir)
await set_amount(seeded, document, "1600", Decimal("1"), current=True) # 1600 ≠ 1700, записей нет
client = await auth_client(KA_RF)
assert (await _statement(client, document))["checks"]["failed"] == []
assert await form_blocker(seeded, document, await _meta(seeded, document)) is None # CHECKS_NOT_PASSED нет
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "млн руб."}})
assert response.status_code == HTTPStatus.OK, response.text
found = [i for i in await _issues(seeded, document) if i.code == "RELATION_FAILED" and i.codes == ["1600", "1700"]]
assert found and all(i.line_ids for i in found)
assert response.json()["checks"]["failed"]
async def test_statements_version_mismatch(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
meta = await _meta(seeded, document)
job = await seeded.get(RecognitionJob, meta.recognition_job_id)
assert job is not None
client = await auth_client(KA_RF)
job.statements_version = "abc1234"
await seeded.flush()
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}})
assert response.status_code == HTTPStatus.OK, response.text
mismatch = [i for i in await _issues(seeded, document) if i.code == "STATEMENTS_VERSION_MISMATCH"]
assert len(mismatch) == 1
assert mismatch[0].severity is IssueSeverity.info and mismatch[0].source is IssueSource.core
job.statements_version = None # у задания 1.x версии нет — запись не пишется (и прежняя заменяется)
await seeded.flush()
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "тыс. руб."}})
assert response.status_code == HTTPStatus.OK, response.text
assert "STATEMENTS_VERSION_MISMATCH" not in await _issue_codes(seeded, document)
job.statements_version = default_engine().version # версия совпала — тоже без записи
await seeded.flush()
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "млн руб."}})
assert response.status_code == HTTPStatus.OK, response.text
assert "STATEMENTS_VERSION_MISMATCH" not in await _issue_codes(seeded, document)
async def test_relative_column_follows_report_date(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
columns = await _db_columns(seeded, document)
relative, manual = columns[1], columns[2]
relative.period_kind = manual.period_kind = "relative:prev_year_end"
await seeded.flush()
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
assert (await client.patch(url, json={"header": {"report_date": "2026-03-31"}})).status_code == HTTPStatus.OK
assert (await _db_columns(seeded, document))[1].period_date == datetime.date(2025, 12, 31)
# ручная дата делает колонку absolute — дальше дата отчёта её не двигает (A12)
response = await client.patch(url, json={"columns": [{"column_id": str(manual.id), "period_date": "2024-06-30"}]})
assert response.status_code == HTTPStatus.OK, response.text
assert (await client.patch(url, json={"header": {"report_date": "2025-09-30"}})).status_code == HTTPStatus.OK
fresh = await _db_columns(seeded, document)
assert fresh[1].period_date == datetime.date(2024, 12, 31) and fresh[2].period_date == datetime.date(2024, 6, 30)
assert (await client.patch(url, json={"header": {"report_date": None}})).status_code == HTTPStatus.OK
fresh = await _db_columns(seeded, document)
assert fresh[1].period_date is None and fresh[2].period_date == datetime.date(2024, 6, 30)
async def test_unknown_relative_kind_is_skipped(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""T2.8: `relative:*` вне перечня пакета — правка даты отчёта не падает, колонка не пересчитывается."""
document = await form(seeded, fixtures_dir)
columns = await _db_columns(seeded, document)
unknown, known = columns[1], columns[2]
unknown.period_kind = "relative:unknown_kind"
known.period_kind = "relative:prev_year_end"
before = unknown.period_date
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2026-03-31"}})
assert response.status_code == HTTPStatus.OK, response.text
fresh = await _db_columns(seeded, document)
assert fresh[1].period_date == before and fresh[2].period_date == datetime.date(2025, 12, 31)
async def test_checks_from_issues_with_codes_and_line_ids(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""`checks.failed`, `NOTHING_TO_ACCEPT` и `CHECKS_NOT_PASSED` читают запись `document_issue`, а не сумм формы."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
nothing = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "x"})
assert nothing.status_code == HTTPStatus.CONFLICT and nothing.json()["detail"]["code"] == "NOTHING_TO_ACCEPT"
line_1600, column_id, _ = await _cell(client, document, "1600", 0)
seeded.add(
DocumentIssue(
document_id=document.id,
kind=DocumentIssueKind.relation,
code="RELATION_FAILED",
severity=IssueSeverity.error,
message="не сошлось 1600 = 1700: пусто ≠ 5",
line_ids=[line_1600],
column_id=uuid.UUID(column_id),
codes=["1600", "1700"],
source=IssueSource.core,
relation="1600 = 1700",
expected=None,
actual=Decimal(5),
created_at=NOW,
)
)
await seeded.flush()
(failed,) = (await _statement(client, document))["checks"]["failed"]
assert failed["codes"] == ["1600", "1700"] and failed["line_ids"] == [line_1600]
assert failed["relation"] == "1600 = 1700" and failed["expected"] is None and failed["actual"] == "5.00"
blocked = await client.post(f"/documents/{document.id}/confirm")
assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED"
assert blocked.json()["detail"]["failures"][0]["expected"] is None
# T2.4: `column_index` записи пуст, колонка известна по `column_id` — индекс один и в ответе формы, и в detail
assert failed["column_index"] == 0 and blocked.json()["detail"]["failures"][0]["column_index"] == 0
async def test_one_patch_one_recompute(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, app: FastAPI
) -> None:
"""`PATCH` из трёх частей: один вызов пересчёта пакета и одно событие аудита."""
fake = FakeEngine()
app.dependency_overrides[get_statements_engine] = lambda: fake
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
line_id, column_id, amount = line["id"], cell["column_id"], cell["amount"]
response = await client.patch(
f"/documents/{document.id}/statement",
json={
"lines": [{"line_id": line_id, "confirmed": True}],
"values": [{"line_id": line_id, "column_id": column_id, "amount": str(Decimal(amount) + 1)}],
"header": {"units": "руб."},
},
)
assert response.status_code == HTTPStatus.OK, response.text
assert len(fake.forms) == 1
assert len(await _edit_events(seeded, document)) == 1