arm-core/api/tests/test_api_statements.py
Andrey Epifancev 37e7f40890 feat(statements): PATCH создаёт строку и ячейку по line_code (#80)
CellPatch адресует ячейку ровно одним из line_id / line_code. Строка справочника
редакции, которой нет в форме, создаётся при первой непустой правке
(line_code_original_value = NULL — признак «не из источника»); отсутствующая
ячейка существующей строки тоже создаётся. Адресация (колонка, строка, код)
проверяется целиком до первой вставки, две ячейки новой строки делят одну
строку, повтор line_code после создания даёт 422 UNKNOWN_CELL. Аудит — одно
RECOGNIZED_DATA_EDIT на запрос, у созданной ячейки before = null.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 12:03:59 +04:00

1289 lines
66 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 delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
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, DocumentStatus, DocumentType, FileStatus, PeriodQuarter, 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_consolidation,
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
MEZH_BALANCE_TEMPLATE_ONLY = {"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",
}
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",
"origin",
"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 _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta:
return (
await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id))
).scalar_one()
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")
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"]}
# цель 1700 не проверяется; «1600 = 1700» (цель 1600 есть, 1700 = 0) нарушено — как и без достройки
assert not any(relation.startswith("1700 =") for relation, _ in relations)
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)
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
# мягкая межформенная проверка не блокирует подтверждение: подмена 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_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 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", "1105"): # 1150 есть в форме; 1105 нет в справочнике 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"