"""Пересчёт формы после правки общим кодом (часть Б, B2): чистые преобразования и запись результата в БД (spec arm-core «Пересчёт после правки», P3-8, R-1).""" import uuid from decimal import Decimal from http import HTTPStatus from pathlib import Path from typing import Any import pytest from sqlalchemy.ext.asyncio import AsyncSession from src.domain.statement_aggregate import CodeAggregate from src.models.enums import DocumentIssueKind, IssueSeverity, IssueSource, StatementLineStatus from src.models.statements import FinancialStatementColumn, FinancialStatementLine from src.services.statement_recompute import code_issues, name_issues, relations_to_issues, version_issue from src.services.statements_engine import FailedRelation, StatementChecks from tests.conftest import AuthClientFactory from tests.test_api_statements import ( KA_RF, _db_value, _issues, _statement, form, recompute_now, ) def _failure(key: str, relation: str, codes: tuple[str, ...], expected: Decimal | None, actual: Decimal | None) -> Any: return FailedRelation( column_index=1, column_key=key, relation=relation, codes=codes, expected=expected, actual=actual ) def _aggregate(code: str, *line_ids: uuid.UUID) -> CodeAggregate: return CodeAggregate(code=code, amounts={}, line_ids=line_ids, reference_name=None, has_source_line=True) def test_relations_to_issues_kinds_columns_and_empty_amounts() -> None: column = FinancialStatementColumn(id=uuid.uuid4(), column_index=1) line_a, line_b, line_c = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() checks = StatementChecks( relations_failed=( _failure(str(column.id), "1600 = 1700", ("1600", "1700"), None, Decimal(5)), _failure("1", "1100 = 1110", ("1100", "1110", "9999"), Decimal(2), Decimal(3)), # ключ — индекс (`2.0`) ), soft_relations_failed=(_failure("77", "1110 = 11101", ("1110", "11101"), Decimal(1), None),), ) aggregates = { "1600": _aggregate("1600", line_a), "1700": _aggregate("1700", line_b, line_c), "1100": _aggregate("1100", line_a), } hard_empty, hard_by_index, soft = relations_to_issues( checks, columns=[column], aggregates=aggregates, source=IssueSource.core ) assert (hard_empty.kind, hard_empty.code, hard_empty.severity) == ( DocumentIssueKind.relation, "RELATION_FAILED", IssueSeverity.error, ) assert hard_empty.column_id == column.id and hard_empty.line_ids == (line_a, line_b, line_c) assert hard_empty.expected is None and hard_empty.actual == Decimal(5) assert hard_empty.message == "не сошлось 1600 = 1700: пусто ≠ 5" assert hard_by_index.column_id == column.id # ключ `1` — индекс колонки assert hard_by_index.line_ids == (line_a,) # кода нет в форме — вклада нет assert (soft.kind, soft.code, soft.severity) == (DocumentIssueKind.soft, "RELATION_SOFT", IssueSeverity.info) assert soft.column_id is None and soft.column_index == 1 and soft.message.endswith("1 ≠ пусто") def _line( code: str | None, *, row: int, status: StatementLineStatus = StatementLineStatus.ok ) -> FinancialStatementLine: return FinancialStatementLine( id=uuid.uuid4(), meta_id=uuid.uuid4(), line_code=code, row_index=row, line_status=StatementLineStatus.ok, line_name=code, ) def test_code_issues_only_four_digit_codes_one_record_per_code() -> None: """R-1 (Р-40): пятизначные коды не проверяются; одна запись на код, `line_ids` — все строки `ok` кода.""" first, second, five, detail = _line("1999", row=1), _line("1999", row=2), _line("19999", row=3), _line(None, row=4) drafts = code_issues( [second, first, five, detail], in_dictionary={"1999": False, "19999": False}, in_edition={"1999": False, "19999": False}, ) assert [(d.code, d.severity, d.source) for d in drafts] == [ ("CODE_NOT_IN_DICTIONARY", IssueSeverity.info, IssueSource.core), ("CODE_NOT_IN_EDITION", IssueSeverity.info, IssueSource.core), ] assert all(d.line_ids == (first.id, second.id) for d in drafts) assert code_issues([first], in_dictionary={"1999": False}, in_edition=None)[0].code == "CODE_NOT_IN_DICTIONARY" def test_name_and_version_issues() -> None: line = _line("1120", row=1) (draft,) = name_issues([line], [line.id]) assert draft.code == "NAME_MISMATCH" and draft.line_ids == (line.id,) and draft.source is IssueSource.core assert version_issue(None, "def5678") is None and version_issue("def5678", "def5678") is None mismatch = version_issue("abc1234", "def5678") assert mismatch is not None and mismatch.code == "STATEMENTS_VERSION_MISMATCH" assert mismatch.severity is IssueSeverity.info and mismatch.source is IssueSource.core @pytest.mark.integration @pytest.mark.fixtures async def test_code_change_recomputes_core_issues( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """P3-8 (T13.4): после смены кода `CODE_NOT_IN_*` пересчитываются — только четырёхзначные (R-1).""" document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) body = await _statement(client, document) ok_lines = [line for line in body["lines"] if line["id"] is not None and line["line_status"] == "ok"] first, second = ok_lines[0], ok_lines[1] url = f"/documents/{document.id}/statement" response = await client.patch( url, json={ "lines": [{"line_id": first["id"], "line_code": "1999"}, {"line_id": second["id"], "line_code": "19999"}] }, ) assert response.status_code == HTTPStatus.OK, response.text issues = await _issues(seeded, document) by_code = {i.code: i for i in issues if i.code.startswith("CODE_NOT_IN_")} assert set(by_code) == {"CODE_NOT_IN_DICTIONARY", "CODE_NOT_IN_EDITION"} assert all(i.line_ids == [first["id"]] for i in by_code.values()) # пятизначный 19999 не проверяется response = await client.patch(url, json={"lines": [{"line_id": first["id"], "line_code": first["line_code"]}]}) assert response.status_code == HTTPStatus.OK, response.text assert not [i for i in await _issues(seeded, document) if i.code.startswith("CODE_NOT_IN_")] @pytest.mark.integration @pytest.mark.fixtures async def test_name_mismatch_follows_code_confirmation( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """P3-8: `NAME_MISMATCH` пересчитывается; подтверждённому (`code_fixed`) коду не ставится.""" document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) body = await _statement(client, document) target = next(line for line in body["lines"] if line["line_code"] == "1120" and line["id"] is not None) line = await seeded.get(FinancialStatementLine, uuid.UUID(target["id"])) assert line is not None line.line_code_confidence, line.line_name = Decimal(50), "Нематериальные активы" await seeded.flush() await recompute_now(seeded, document) mismatch = [i for i in await _issues(seeded, document) if i.code == "NAME_MISMATCH"] assert [i.line_ids for i in mismatch] == [[target["id"]]] response = await client.patch( f"/documents/{document.id}/statement", json={"lines": [{"line_id": target["id"], "confirmed": True}]} ) assert response.status_code == HTTPStatus.OK, response.text assert not [i for i in await _issues(seeded, document) if i.code == "NAME_MISMATCH"] @pytest.mark.integration @pytest.mark.fixtures async def test_values_of_line_leaving_detail_are_recomputed( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: """T13.3: `needs_review` значений строки, ушедшей в `detail`, не залипает на `false` — после назначения кода его даёт пересчёт пакета.""" document = await form(seeded, fixtures_dir) client = await auth_client(KA_RF) body = await _statement(client, document) target = next(line for line in body["lines"] if line["id"] is not None and line["values"][0]["amount"] is not None) column_id = target["values"][0]["column_id"] value = await _db_value(seeded, target["id"], column_id) value.confidence = Decimal(80) await seeded.flush() url = f"/documents/{document.id}/statement" response = await client.patch(url, json={"lines": [{"line_id": target["id"], "line_status": "detail"}]}) assert response.status_code == HTTPStatus.OK, response.text assert (await _db_value(seeded, target["id"], column_id)).needs_review is False response = await client.patch(url, json={"lines": [{"line_id": target["id"], "line_code": "1999"}]}) assert response.status_code == HTTPStatus.OK, response.text assert (await _db_value(seeded, target["id"], column_id)).needs_review is True # код вне соотношений — не покрыта