arm-core/api/tests/test_statements_v2_edit.py
Andrey Epifancev e6292a24df fix(statements): fix-волна части Б — is_recognized NULL, единый источник ошибки файла, тесты правки форм 2.0
I-2: NULL у is_recognized не сомнение (P3-54); I-3: metrics.file_warnings при приёме 2.0 — единый
источник для пересчёта (P3-53, R-11); I-4/T4.4/T6.1: тесты правки, акцепта и веток приёма 2.0;
T4.2: now в checks_on_intake_1x; M-4: row_by_ref по принятым строкам; документация (M-5, M-6, M-7).

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

296 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Правка и пересчёт формы, принятой маппером 2.0 (fix-волна части Б: I-2, I-3, I-4, T4.2, T4.4, T6.1; P3-53).
Форма загружается `map_file` с результатом `2.0` и реальным пакетом `arm_statements`, правится через
`PATCH …/statement`. Проверяется, что пересчёт заменяет только записи ядра (`relation` / `soft` и коды
`RECOMPUTED_CORE_CODES`), сохраняет записи сервиса и факты приёма и не меняет статус от несвязанной правки.
"""
from http import HTTPStatus
from typing import Any
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.models.enums import DocumentIssueKind, DocumentStatus, IssueSeverity, IssueSource
from src.models.files import Document, RecognitionJob
from src.models.statements import FinancialStatementValue
from src.services.statement_intake_v2 import FILE_WARNINGS_KEY, file_error_in_metrics
from src.services.statements_engine import DocumentType as DocumentType_
from src.services.statements_engine import default_engine
from tests.conftest import AuthClientFactory
from tests.factories_v2 import balance_lines, column_v2, header_v2, line_v2, statement_doc_v2
from tests.test_api_statements import _cell
from tests.test_mapping_statements_v2 import (
ERROR_JSON_FAILED,
KA_RF,
_confirm_clean,
_entity,
_issues,
_lines,
_map,
_meta,
_osv_doc,
)
pytestmark = pytest.mark.integration
TABLE_ERROR = {"code": "TABLE_PARSE_ERROR", "severity": "error", "message": "таблица разобрана с ошибкой"}
def _consistent_balance() -> dict[str, Any]:
"""Баланс, у которого сходится и пересчёт ядра: 1310 = 1300 = 1700, 1210 = 1200 = 1600 (реальный пакет). Строка
9999 вне соотношений §7 — её ячейку не «покрывает» ни одна проверка, поэтому `needs_review` зависит только от
`is_recognized` и уверенности (I-2)."""
lines = [*balance_lines(), line_v2(5, "1310", "Уставный капитал"), line_v2(6, "9999", "Прочая строка", amount="5")]
return statement_doc_v2(lines=lines)
async def _form(session: AsyncSession, *documents: dict[str, Any], **kwargs: Any) -> Document:
"""Первая форма файла 2.0 владельца с доступом `ka_rf` (по умолчанию — `_consistent_balance`)."""
entity = await _entity(session, rostov=True)
return (await _map(session, entity, list(documents) or [_consistent_balance()], **kwargs))[0]
async def _patch_units(client: Any, document: Document, units: str = "млн руб.") -> None:
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": units}})
assert response.status_code == HTTPStatus.OK, response.text
async def _stub_values(session: AsyncSession, document: Document) -> int:
"""Заглушки маппера 1.x/2.0: `is_recognized = NULL` у всех значений формы; возвращает их число."""
line_ids = [line.id for line in await _lines(session, document)]
stmt = select(FinancialStatementValue).where(FinancialStatementValue.line_id.in_(line_ids))
values = list((await session.execute(stmt)).scalars())
for value in values: # через ORM: значения уже в сессии теста, SQL-UPDATE пересчёт не увидел бы
value.is_recognized = None
await session.flush()
return len(values)
# --- I-2: NULL у is_recognized не сомнение ---------------------------------------------------------------------------
async def test_null_is_recognized_stub_does_not_need_review_after_header_edit(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""I-2: после правки шапки значения с `is_recognized = NULL` (заглушки) не получают `needs_review`, форма остаётся
`RECOGNIZED`."""
document = await _form(seeded)
assert document.status is DocumentStatus.RECOGNIZED
assert await _stub_values(seeded, document) > 0
await _patch_units(await auth_client(KA_RF), document)
await seeded.refresh(document)
assert document.status is DocumentStatus.RECOGNIZED
values = (await seeded.execute(select(FinancialStatementValue))).scalars().all()
assert values and not any(value.needs_review for value in values)
assert not any(line.needs_review for line in await _lines(seeded, document))
async def test_cell_filled_and_cleared_back_keeps_recognized(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""I-2: ячейка с `is_recognized = NULL` заполнена другой суммой и возвращена к исходной — `RECOGNIZED`."""
document = await _form(seeded)
await _stub_values(seeded, document)
client = await auth_client(KA_RF)
line_id, column_id, original = await _cell(client, document, "1210", 2)
assert original is not None
url = f"/documents/{document.id}/statement"
changed = await client.patch(url, json={"values": [{"line_id": line_id, "column_id": column_id, "amount": "7"}]})
assert changed.status_code == HTTPStatus.OK, changed.text
back = await client.patch(url, json={"values": [{"line_id": line_id, "column_id": column_id, "amount": original}]})
assert back.status_code == HTTPStatus.OK, back.text
await seeded.refresh(document)
assert document.status is DocumentStatus.RECOGNIZED
assert not any(value.needs_review for value in (await seeded.execute(select(FinancialStatementValue))).scalars())
# --- I-3 (R-11, P3-53): единый источник «ошибка файла» --------------------------------------------------------------
def test_file_error_in_metrics_prefers_file_warnings_and_falls_back() -> None:
error = [{"code": "X", "severity": "error", "message": "m"}]
assert file_error_in_metrics({FILE_WARNINGS_KEY: error}) is True
assert file_error_in_metrics({FILE_WARNINGS_KEY: [], "warnings": error}) is False # приоритет у ключа приёма 2.0
assert file_error_in_metrics({"warnings": error}) is True # результат 1.x и задания до части Б
assert file_error_in_metrics({"warnings": ["строка 1.x"]}) is False
assert file_error_in_metrics(None) is False and file_error_in_metrics({}) is False
async def test_file_error_warning_is_saved_and_holds_needs_check_after_edit(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""P3-53: предупреждение файла результата `error` сохраняется в `metrics.file_warnings`, форма `NEEDS_CHECK`, и
несвязанная правка шапки статус не меняет (конверт `metrics.warnings` в тесте пуст — источник один)."""
document = await _form(seeded, file_warnings=[ERROR_JSON_FAILED])
job = await seeded.get(RecognitionJob, (await _meta(seeded, document)).recognition_job_id)
assert job is not None and job.metrics is not None
assert [w["code"] for w in job.metrics[FILE_WARNINGS_KEY]] == ["JSON_FAILED"]
assert document.status is DocumentStatus.NEEDS_CHECK
await _patch_units(await auth_client(KA_RF), document)
await seeded.refresh(document)
assert document.status is DocumentStatus.NEEDS_CHECK
async def test_info_file_warning_keeps_recognized_after_edit(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
info = {"code": "SOMETHING", "severity": "info", "message": "к сведению"}
document = await _form(seeded, file_warnings=[info])
await _patch_units(await auth_client(KA_RF), document)
await seeded.refresh(document)
assert document.status is DocumentStatus.RECOGNIZED
# --- I-4: правка и акцепт формы 2.0 ----------------------------------------------------------------------------------
async def test_service_relations_replaced_by_core_after_edit(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""I-4: записи `relation` / `soft` источника `service` после первой правки заменяются записями `core` (суммы
сходятся — нарушений ядра нет), warning сервиса уровня `error` остаётся и держит `NEEDS_CHECK`."""
checks = {
"relations_failed": [
{"column_index": 2, "relation": "1600 = 1700", "codes": ["1600", "1700"], "expected": "1", "actual": "2"}
],
"soft_relations_failed": [
{"column_index": 2, "relation": "1210 = 1200", "codes": ["1210", "1200"], "expected": "1", "actual": "2"}
],
}
doc = {**_consistent_balance(), "status": "needs_check", "warnings": [TABLE_ERROR], "checks": checks}
document = await _form(seeded, doc)
before = await _issues(seeded, document)
service_checks = [i for i in before if i.kind in (DocumentIssueKind.relation, DocumentIssueKind.soft)]
assert len(service_checks) == 2 and all(i.source is IssueSource.service for i in service_checks) # noqa: PLR2004
await _patch_units(await auth_client(KA_RF), document)
after = await _issues(seeded, document)
assert not [i for i in after if i.kind in (DocumentIssueKind.relation, DocumentIssueKind.soft)]
kept = [i for i in after if i.code == "TABLE_PARSE_ERROR"]
assert len(kept) == 1 and kept[0].source is IssueSource.service and kept[0].severity is IssueSeverity.error
await seeded.refresh(document)
assert document.status is DocumentStatus.NEEDS_CHECK
async def test_result_status_inconsistent_survives_edit(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""I-4: `RESULT_STATUS_INCONSISTENT` — факт приёма, правка шапки его не удаляет; форма остаётся `NEEDS_CHECK`."""
lines = [*balance_lines(), line_v2(9, None, "Строка", status="unreadable", amount="50", code_text="???")]
document = await _form(seeded, statement_doc_v2(lines=lines, status="done"))
assert [i.code for i in await _issues(seeded, document) if i.code == "RESULT_STATUS_INCONSISTENT"]
await _patch_units(await auth_client(KA_RF), document)
assert len([i for i in await _issues(seeded, document) if i.code == "RESULT_STATUS_INCONSISTENT"]) == 1
await seeded.refresh(document)
assert document.status is DocumentStatus.NEEDS_CHECK
async def test_recognized_form_stays_recognized_after_header_edit_and_accept(
seeded: AsyncSession, auth_client: AuthClientFactory
) -> None:
"""I-4: статус `RECOGNIZED` формы 2.0 не меняется от правки шапки (units); акцепт без нарушений — 409."""
document = await _form(seeded)
assert document.status is DocumentStatus.RECOGNIZED
client = await auth_client(KA_RF)
await _patch_units(client, document)
await seeded.refresh(document)
assert document.status is DocumentStatus.RECOGNIZED
nothing = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "проверено"})
assert nothing.status_code == HTTPStatus.CONFLICT and nothing.json()["detail"]["code"] == "NOTHING_TO_ACCEPT"
async def test_accept_form_with_core_violation_after_edit(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""I-4: у формы 2.0 с нарушением, найденным ядром после правки (1600 ≠ 1700), акцепт возможен; запись `relation`
(`core`) остаётся, форма подтверждается (`CHECKS_NOT_PASSED` снят акцептом)."""
lines = [*balance_lines(), line_v2(5, "1310", "Уставный капитал", amount="50")]
document = await _form(seeded, statement_doc_v2(lines=lines))
client = await auth_client(KA_RF)
await _patch_units(client, document)
assert [i for i in await _issues(seeded, document) if i.kind is DocumentIssueKind.relation]
await seeded.refresh(document)
assert document.status is DocumentStatus.NEEDS_CHECK
blocked = await client.post(f"/documents/{document.id}/confirm")
assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED"
accepted = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "проверено"})
assert accepted.status_code == HTTPStatus.OK, accepted.text
assert [i for i in await _issues(seeded, document) if i.kind is DocumentIssueKind.relation]
confirmed = await client.post(f"/documents/{document.id}/confirm")
assert confirmed.status_code == HTTPStatus.OK, confirmed.text
# --- T4.4: ветки приёма 2.0 -------------------------------------------------------------------------------------------
async def test_intake_duplicate_row_and_missing_parent_warnings(seeded: AsyncSession) -> None:
"""T4.4: дубль `row_index` / `ref` пропущен, родитель не найден — предупреждения маппинга (`MAPPING_WARNING`)."""
lines = [
*balance_lines(),
line_v2(0, "1230", "Дубль строки 0"),
line_v2(20, None, "сирота", status="detail", ref="r:orphan", parent_ref="r:none", code_confidence=None),
]
document = await _form(seeded, statement_doc_v2(lines=lines))
messages = [i.message for i in await _issues(seeded, document) if i.code == "MAPPING_WARNING"]
assert any("повторяет прежнюю" in m for m in messages) and any("r:none" in m for m in messages)
assert len(await _lines(seeded, document)) == len(balance_lines()) + 1 # дубль не сохранён, сирота — да
async def test_intake_statements_version_mismatch(seeded: AsyncSession) -> None:
"""T4.4: версия пакета сервиса отличается от версии копии ядра — `STATEMENTS_VERSION_MISMATCH` (`info`, `core`)."""
document = await _form(seeded, statements_version="0000000")
mismatch = [i for i in await _issues(seeded, document) if i.code == "STATEMENTS_VERSION_MISMATCH"]
assert len(mismatch) == 1 and mismatch[0].severity is IssueSeverity.info and mismatch[0].source is IssueSource.core
document_ok = await _form_again_same_version(seeded)
assert not [i for i in await _issues(seeded, document_ok) if i.code == "STATEMENTS_VERSION_MISMATCH"]
async def _form_again_same_version(session: AsyncSession) -> Document:
entity = await _entity(session, inn="7711111111", rostov=True)
return (await _map(session, entity, [statement_doc_v2()], statements_version=default_engine().version))[0]
async def test_intake_disclosure_with_error_warning_is_recognized(seeded: AsyncSession) -> None:
"""T4.4 (= P3-41, вопрос владельцу): `DISCLOSURE` с предупреждением `error` — один документ `RECOGNIZED`, запись
предупреждения сохранена."""
disclosure = statement_doc_v2("DISCLOSURE", lines=[], columns=[], form_variant=None, warnings=[TABLE_ERROR])
entity = await _entity(seeded, rostov=True)
balance, other = await _map(seeded, entity, [statement_doc_v2(), disclosure])
assert other.status is DocumentStatus.RECOGNIZED and balance.status is DocumentStatus.RECOGNIZED
(issue,) = await _issues(seeded, other)
assert (issue.code, issue.severity) == ("TABLE_PARSE_ERROR", IssueSeverity.error)
async def test_intake_edition_none_has_no_code_issues_but_name_mismatch(seeded: AsyncSession) -> None:
"""T4.4: редакция не определена (`edition is None`) — `CODE_NOT_IN_*` не пишутся, `NAME_MISMATCH` остаётся."""
# коды общие для обеих редакций и дата неизвестна — ничья без даты, редакцию выбрать нельзя
names = [("1200", "Итого по разделу I"), ("1600", "БАЛАНС")]
lines = [line_v2(i, code, name, code_confidence=50) for i, (code, name) in enumerate(names)]
undated = statement_doc_v2(
lines=lines,
header=header_v2(report_date=None, report_date_text=None, report_date_confidence=None),
columns=[column_v2(2, date=None, kind="none", label="Показатель")],
)
document = await _form(seeded, undated)
pairs = [(code, False) for code, _ in names]
assert default_engine().edition(DocumentType_.BALANCE, pairs, None, None) is None
assert document.reporting_date is None
codes = {i.code for i in await _issues(seeded, document)}
assert "NAME_MISMATCH" in codes
assert not codes & {"CODE_NOT_IN_DICTIONARY", "CODE_NOT_IN_EDITION"}
# --- T6.1: предупреждение листа ОСВ уровня error (P3-49) ------------------------------------------------------------
async def test_osv_v2_sheet_warning_error_does_not_block(seeded: AsyncSession) -> None:
"""T6.1 (P3-49): предупреждение листа ОСВ `severity = error` при `done` листа и сошедшихся суммах подтверждение не
блокирует — решает статус листа и ошибка файла, а не уровень предупреждения листа."""
entity = await _entity(seeded)
osv = _osv_doc()
osv["warnings"] = [{"code": "OSV_HIERARCHY_BY_OUTLINE", "severity": "error", "message": "иерархия по outline"}]
(document,) = await _map(seeded, entity, [osv])
assert document.status is DocumentStatus.RECOGNIZED
issue = (await _issues(seeded, document))[0]
assert issue.code == "OSV_HIERARCHY_BY_OUTLINE" and issue.severity is IssueSeverity.error
assert issue.source is IssueSource.service
await _confirm_clean(seeded, [document])
assert document.status is DocumentStatus.CONFIRMED