fixtures #26/#27 (19.10.2026) перевели снимки форм и OCR на контракт 2.0, а загрузчики тестов читали их моделью 1.x: 164 падения на main. - golden_result/ocr_result: модель по MAJOR schema_version, для 2.0 — service.statements_version копии пакета ядра; - golden_result_v1: снимок 2.0 → 1.2 (строки с кодом, колонки с 0, без null в шапке) для модулей приёма 1.x — test_mapping_statements и test_api_statements; путь 2.0 покрывают *_v2; - make_entries_from_result — parse_result; ожидания fixtures_marker под эталоны 2.0; удаление файла считает замечания сервиса снимка; - 76.Н.1 (synthetic s42, tabular-parser#59) — в KNOWN_OUT_OF_PLAN. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2570 lines
128 KiB
Python
2570 lines
128 KiB
Python
"""API формы ББ/ОФР (spec documents-api «Форма ББ/ОФР для экрана проверки», «Правка формы», «Акцепт проверок формы»,
|
||
«Подтверждение документа»; statement-checks; design D-5, D-7…D-9, D-14)."""
|
||
|
||
import datetime
|
||
import functools
|
||
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, golden_result_v1
|
||
from tests.fixtures_helpers import map_golden_snapshot as _map_golden_snapshot
|
||
|
||
pytestmark = [pytest.mark.integration, pytest.mark.fixtures]
|
||
|
||
# формы — из снимков, сведённых к 1.2 (модуль писался под приём `1.x`); правка формы 2.0 — `test_statements_v2_edit.py`
|
||
map_golden_snapshot = functools.partial(_map_golden_snapshot, result_builder=golden_result_v1)
|
||
|
||
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
|