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>
296 lines
18 KiB
Python
296 lines
18 KiB
Python
"""Правка и пересчёт формы, принятой маппером 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
|