arm-core/api/tests/test_api_statements.py
Andrey Epifancev 38a6a1b0c2 feat(statements): правка формы (значения, ИНН, дата, единицы) и акцепт проверок — аудит, пересчёт статуса (D-7, D-8)
PATCH /documents/{id}/statement и POST /documents/{id}/statement/checks/accept: блокировки «файл → документ»,
409 FILE_SUPERSEDED/DOCUMENT_READONLY/DOCUMENT_NOT_STATEMENT, 422 INVALID_INN/UNKNOWN_CELL/DUPLICATE_CELL,
один audit_event на запрос, сброс акцепта правкой, пересчёт статуса D-6.

Замечания ревью Task 5: пара формы ищется только среди документов с шапкой формы (T5.1), строки формы упорядочены
по (line_code, id) (T5.3), слабый тест межформенной проверки удалён (T5.4), локальный импорт в тестах убран (T5.6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 12:09:43 +04:00

481 lines
23 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
import pytest
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.statement_checks import CROSS_FORM_RELATION
from src.models.audit_ex import AuditEvent
from src.models.directories import LegalEntity, RegionalOffice
from src.models.enums import AuditAction, DocumentStatus, DocumentType, FileStatus, RecognitionStatus
from src.models.files import Document, StoredFile
from src.models.statements import (
FinancialStatementColumn,
FinancialStatementLine,
FinancialStatementMeta,
FinancialStatementValue,
)
from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_document, make_legal_entity, make_stored_file
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
KA_RF = "ka_rf@armka.local"
RKA = "rka@armka.local" # только DOCUMENT_VIEW
ROSTOV = "Ростовский региональный филиал"
MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json"
MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
MEZH_BALANCE_LINES = 37
async def rostov_entity(session: AsyncSession, path: Path, name: str = "ООО Межениновская") -> LegalEntity:
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
group = await make_company_group(session, name=f"ГК {name}", office=office)
data = json.loads(path.read_text("utf-8"))
inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None)
return await make_legal_entity(session, name=name, inn=inn, group=group)
async def form(session: AsyncSession, fixtures_dir: Path, path: Path = MEZH_BALANCE) -> Document:
entity = await rostov_entity(session, fixtures_dir / path)
documents = await map_golden_snapshot(session, entity=entity, path=fixtures_dir / path, uploaded_by=None)
return next(d for d in documents if d.document_type in (DocumentType.BALANCE, DocumentType.INCOME))
async def two_forms(session: AsyncSession, fixtures_dir: Path) -> tuple[Document, Document]:
"""ББ и ОФР Межениновской за 2025 Q3 одного ЮЛ (два файла)."""
entity = await rostov_entity(session, fixtures_dir / MEZH_BALANCE)
balance = next(
d
for d in await map_golden_snapshot(session, entity=entity, path=fixtures_dir / MEZH_BALANCE, uploaded_by=None)
if d.document_type == DocumentType.BALANCE
)
income = next(
d
for d in await map_golden_snapshot(session, entity=entity, path=fixtures_dir / MEZH_INCOME, uploaded_by=None)
if d.document_type == DocumentType.INCOME
)
return balance, income
async def set_amount( # noqa: PLR0913 - адресация ячейки: документ, строка, колонка
session: AsyncSession,
document: Document,
line_code: str,
amount: Decimal,
*,
current: bool = False,
period_date: datetime.date | None = None,
) -> None:
"""Прямая правка суммы ячейки в БД теста (колонка `is_current` или с датой `period_date`)."""
meta = (
await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id))
).scalar_one()
column_filter = (
FinancialStatementColumn.is_current.is_(True)
if current
else FinancialStatementColumn.period_date == period_date
)
column_id = (
await session.execute(
select(FinancialStatementColumn.id)
.where(FinancialStatementColumn.meta_id == meta.id, column_filter)
.order_by(FinancialStatementColumn.column_index)
.limit(1)
)
).scalar_one()
line_id = (
await session.execute(
select(FinancialStatementLine.id).where(
FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == line_code
)
)
).scalar_one()
await session.execute(
update(FinancialStatementValue)
.where(FinancialStatementValue.line_id == line_id, FinancialStatementValue.column_id == column_id)
.values(amount=amount)
)
await session.flush()
async def test_get_statement(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
response = await client.get(f"/documents/{document.id}/statement")
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()
assert body["edition"] == "fsbu4"
assert body["document"]["document_type"] == "BALANCE" and body["document"]["period_quarter"] == "Q3"
assert len(body["lines"]) == MEZH_BALANCE_LINES
codes = [line["line_code"] for line in body["lines"]]
assert codes == sorted(codes)
columns = len(body["columns"])
assert all(len(line["values"]) == columns for line in body["lines"])
assert body["checks"]["failed"] == [] and body["checks"]["accepted"] is None
assert set(body["header"]["inn"]) == {"value", "original_value", "confidence", "verified", "modified"}
async def test_get_statement_shape_and_decimal_strings(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Документ, шапка и значения: поля спеки, суммы — строкой, `column_index` значения совпадает с колонкой."""
document = await form(seeded, fixtures_dir)
client = await auth_client(RKA) # DOCUMENT_VIEW достаточно
body = (await client.get(f"/documents/{document.id}/statement")).json()
assert set(body["document"]) == {
"id",
"document_type",
"status",
"period_year",
"period_quarter",
"reporting_date",
"counterparty_check",
"locator",
"file_id",
}
assert {"organization_name", "okud", "okpo", "okopf", "okfs", "address"} <= set(body["header"])
assert set(body["columns"][0]) == {"id", "column_index", "label", "period_date", "is_current"}
line = next(line for line in body["lines"] if any(v["amount"] is not None for v in line["values"]))
assert set(line) >= {"id", "line_code", "line_code_confidence", "line_name", "line_name_confidence", "values"}
value = next(v for v in line["values"] if v["amount"] is not None)
assert isinstance(value["amount"], str)
assert set(value) == {
"id",
"column_id",
"column_index",
"amount",
"string_value",
"confidence",
"is_recognized",
"is_modified",
"original_amount",
"modified_by",
"modified_at",
}
index_by_id = {column["id"]: column["column_index"] for column in body["columns"]}
assert all(index_by_id[v["column_id"]] == v["column_index"] for line in body["lines"] for v in line["values"])
assert [v["column_index"] for v in line["values"]] == sorted(v["column_index"] for v in line["values"])
async def test_get_income_statement(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir, MEZH_INCOME)
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{document.id}/statement")).json()
assert body["document"]["document_type"] == "INCOME" and body["edition"] == "fsbu4"
assert body["lines"]
async def test_statement_of_osv_is_conflict(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО ОСВ")
file, job = await make_stored_file(
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
osv = await make_document(seeded, file=file, job=job)
client = await auth_client(KA_RF)
response = await client.get(f"/documents/{osv.id}/statement")
assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_NOT_STATEMENT"
async def test_statement_hidden_is_404(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client("client_manager@armka.local") # другой филиал
assert (await client.get(f"/documents/{document.id}/statement")).status_code == HTTPStatus.NOT_FOUND
async def test_cross_form_violation_is_reported_on_both_forms(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Детерминированно (D-5): 1370 = 500 на 30.09.2025 и 300 на 31.12.2024, 2400 = 150 → expected 200, actual 150."""
balance, income = await two_forms(seeded, fixtures_dir)
await set_amount(seeded, balance, "1370", Decimal("500"), current=True)
await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31))
await set_amount(seeded, income, "2400", Decimal("150"), current=True)
client = await auth_client(KA_RF)
for document in (balance, income):
body = (await client.get(f"/documents/{document.id}/statement")).json()
assert body["checks"]["cross_form"] == {
"column_index": -1,
"relation": CROSS_FORM_RELATION,
"expected": "200.00",
"actual": "150.00",
}, document.document_type
# соотношение выполнено: 500 − 300 = 200 → проверка не нарушена
await set_amount(seeded, income, "2400", Decimal("200"), current=True)
body = (await client.get(f"/documents/{balance.id}/statement")).json()
assert body["checks"]["cross_form"] is None
async def test_cross_form_without_pair_is_null(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
assert (await client.get(f"/documents/{document.id}/statement")).json()["checks"]["cross_form"] is None
async def test_cross_form_ignores_superseded_pair(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Пара из файла `SUPERSEDED` не участвует (D-5)."""
balance, income = await two_forms(seeded, fixtures_dir)
await set_amount(seeded, balance, "1370", Decimal("500"), current=True)
await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31))
await set_amount(seeded, income, "2400", Decimal("150"), current=True)
await seeded.execute(
update(StoredFile).where(StoredFile.id == income.stored_file_id).values(status=FileStatus.SUPERSEDED)
)
await seeded.flush()
client = await auth_client(KA_RF)
assert (await client.get(f"/documents/{balance.id}/statement")).json()["checks"]["cross_form"] is None
async def test_cross_form_pair_without_meta_does_not_shadow(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""T5.1: новее подтверждённый документ того же типа без шапки формы не мешает найти настоящую пару."""
balance, income = await two_forms(seeded, fixtures_dir)
await set_amount(seeded, balance, "1370", Decimal("500"), current=True)
await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31))
await set_amount(seeded, income, "2400", Decimal("150"), current=True)
entity = await seeded.get(LegalEntity, balance.legal_entity_id)
file, job = await make_stored_file(
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
ghost = await make_document(seeded, file=file, job=job, year=2025, quarter=balance.period_quarter)
ghost.document_type = DocumentType.INCOME
ghost.osv_account_code = None
ghost.status = DocumentStatus.CONFIRMED
ghost.created_at = income.created_at + datetime.timedelta(days=1)
await seeded.flush()
client = await auth_client(KA_RF)
body = (await client.get(f"/documents/{balance.id}/statement")).json()
assert body["checks"]["cross_form"]["actual"] == "150.00"
async def _first_cell(client, document_id) -> tuple[dict, dict]:
body = (await client.get(f"/documents/{document_id}/statement")).json()
line = next(line for line in body["lines"] if line["values"][0]["amount"] is not None)
return line, line["values"][0]
async def test_patch_value_and_revert(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
original = cell["amount"]
new = str(Decimal(original) + 20)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": new}]},
)
assert response.status_code == HTTPStatus.OK, response.text
edited = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0]
assert Decimal(edited["amount"]) == Decimal(new) and Decimal(edited["original_amount"]) == Decimal(original)
assert edited["is_modified"] is True and edited["modified_by"] is not None
event = (
(
await seeded.execute(
select(AuditEvent).where(
AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT
)
)
)
.scalars()
.one()
)
assert event.before_value and event.after_value
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": original}]},
)
reverted = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0]
assert reverted["is_modified"] is False and Decimal(reverted["original_amount"]) == Decimal(original)
async def test_patch_report_date_recomputes_period(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-06-30"}})
body = response.json()
assert body["document"]["period_quarter"] == "Q2" and body["header"]["report_date"]["modified"] is True
assert body["header"]["report_date"]["original_value"] is not None # исходник не тронут
async def test_patch_inn_validates_and_rechecks(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
bad = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": "12345"}})
assert bad.status_code == HTTPStatus.UNPROCESSABLE_ENTITY and bad.json()["detail"]["code"] == "INVALID_INN"
other = await client.patch(f"/documents/{document.id}/statement", json={"header": {"inn": "7700000000"}})
assert other.json()["document"]["counterparty_check"] == "MISMATCH"
assert other.json()["document"]["status"] == "NEEDS_CHECK"
async def test_patch_duplicate_cell_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 1."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": "1"}
response = await client.patch(
f"/documents/{document.id}/statement", json={"values": [item, {**item, "amount": "2"}]}
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "DUPLICATE_CELL"
async def test_patch_amount_precision_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 2: больше двух знаков или больше 16 цифр до запятой — 422 схемы, данные не меняются."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, document.id)
for amount in ("1.234", "12345678901234567"):
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": amount}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, amount
_, after = await _first_cell(client, document.id)
assert after["amount"] == cell["amount"] and after["is_modified"] is False
async def test_patch_unknown_cell(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
response = await client.patch(
f"/documents/{document.id}/statement",
json={"values": [{"line_id": str(uuid.uuid4()), "column_id": str(uuid.uuid4()), "amount": "1"}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
async def test_patch_cell_of_other_form_is_unknown(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Ячейка другой формы того же ЮЛ — не ячейка этого документа."""
balance, income = await two_forms(seeded, fixtures_dir)
client = await auth_client(KA_RF)
line, cell = await _first_cell(client, income.id)
response = await client.patch(
f"/documents/{balance.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": "1"}]},
)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "UNKNOWN_CELL"
async def test_patch_requires_edit_right(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(RKA)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}})
assert response.status_code == HTTPStatus.FORBIDDEN
async def test_empty_patch_no_audit(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
assert (await client.patch(f"/documents/{document.id}/statement", json={})).status_code == HTTPStatus.OK
events = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().all()
assert events == []
async def _break_balance(client, document_id) -> None:
body = (await client.get(f"/documents/{document_id}/statement")).json()
line_1600 = next(line for line in body["lines"] if line["line_code"] == "1600")
cell = line_1600["values"][0]
amount = str(Decimal(cell["amount"] or 0) + 1000)
await client.patch(
f"/documents/{document_id}/statement",
json={"values": [{"line_id": line_1600["id"], "column_id": cell["column_id"], "amount": amount}]},
)
async def test_accept_and_reset(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
nothing = await client.post(f"{url}/checks/accept", json={"comment": "ок"})
assert nothing.status_code == HTTPStatus.CONFLICT and nothing.json()["detail"]["code"] == "NOTHING_TO_ACCEPT"
await _break_balance(client, document.id)
broken = (await client.get(url)).json()
assert any(item["relation"].startswith("1600") for item in broken["checks"]["failed"])
assert broken["document"]["status"] == "NEEDS_CHECK"
accepted = await client.post(f"{url}/checks/accept", json={"comment": "округление в форме клиента"})
assert accepted.status_code == HTTPStatus.OK
assert accepted.json()["checks"]["accepted"]["comment"] == "округление в форме клиента"
assert accepted.json()["document"]["status"] == "RECOGNIZED" # других причин NEEDS_CHECK нет (D-6)
await client.patch(url, json={"header": {"units": "руб."}})
after = (await client.get(url)).json()
assert after["checks"]["accepted"] is None and after["document"]["status"] == "NEEDS_CHECK"
async def test_accept_blank_comment_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
response = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": " "})
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
async def test_patch_confirmed_readonly(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
assert (await client.post(f"/documents/{document.id}/confirm")).status_code == HTTPStatus.OK
url = f"/documents/{document.id}/statement"
response = await client.patch(url, json={"header": {"units": "руб."}})
assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_READONLY"
response = await client.post(f"{url}/checks/accept", json={"comment": "ок"})
assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_READONLY"
async def test_patch_superseded_file(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
await seeded.execute(
update(StoredFile).where(StoredFile.id == document.stored_file_id).values(status=FileStatus.SUPERSEDED)
)
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}})
assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "FILE_SUPERSEDED"
async def test_patch_osv_is_not_statement(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
entity = await rostov_entity(seeded, fixtures_dir / MEZH_BALANCE, name="ООО ОСВ 2")
file, job = await make_stored_file(
seeded, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done
)
osv = await make_document(seeded, file=file, job=job)
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{osv.id}/statement", json={"header": {"units": "руб."}})
assert response.status_code == HTTPStatus.CONFLICT and response.json()["detail"]["code"] == "DOCUMENT_NOT_STATEMENT"