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