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