arm-core/api/tests/test_statement_recompute.py
Andrey Epifancev 065153e470 feat(statements): пересчёт после правки точкой входа arm_statements, §7 только из document_issue
recompute_form (services/statement_recompute.py) собирает вход из БД, зовёт engine.recompute и пишет needs_review
строк и значений, form_variant, parent_row_index расшифровок, записи relation/soft и core по P3-8, статус через
form_status_v2. GET/confirm/accept читают нарушения §7 из document_issue. Правка header.report_date пересчитывает
relative:* колонки. Временный код A12/A13 удалён.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 20:26:40 +04:00

184 lines
9.2 KiB
Python
Raw Permalink 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.

"""Пересчёт формы после правки общим кодом (часть Б, 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 # код вне соотношений — не покрыта