From 9e28213ce6ffe4fdc2efdea8125b433652ebe162 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 14:04:19 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=82=D0=BE=D1=87=D0=BA=D0=B0=20=D0=B2?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D1=81=D1=87?= =?UTF-8?q?=D1=91=D1=82=D0=B0=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D1=91=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=BC=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=BC=20(=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BA=D0=B8,=20=D0=BF=D0=BE=D0=B4=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F,=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- arm_statements/__init__.py | 20 ++- arm_statements/recompute.py | 177 +++++++++++++++++++++++++ arm_statements/tests/test_recompute.py | 87 ++++++++++++ 3 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 arm_statements/recompute.py create mode 100644 arm_statements/tests/test_recompute.py diff --git a/arm_statements/__init__.py b/arm_statements/__init__.py index 5b2bcf4..d8027ce 100644 --- a/arm_statements/__init__.py +++ b/arm_statements/__init__.py @@ -1,7 +1,7 @@ """Общий код нормализации форм ББ/ОФР. -Публичный API — только имена `__all__` (раздел «Публичный API пакета» плана 1). Имена задач 18–19 -(`recompute`, `Stored*`, `to_contract`, `warning_to_contract`, `status_input_from_contract`) добавляются ими. +Публичный API — только имена `__all__` (раздел «Публичный API пакета» плана 1). Имена задачи 19 +(`to_contract`, `warning_to_contract`, `status_input_from_contract`) добавляются ими. """ from arm_statements.amounts import parse_amount @@ -43,6 +43,15 @@ from arm_statements.model import ( StatementWarning, ) from arm_statements.pipeline import normalize, normalize_candidate +from arm_statements.recompute import ( + RecomputeResult, + StoredColumn, + StoredForm, + StoredLine, + StoredValue, + StoredWarning, + recompute, +) from arm_statements.serialize import candidate_from_dict, to_jsonable from arm_statements.status import ( StatusInput, @@ -75,6 +84,7 @@ __all__ = [ "LineStatus", "NormalizationResult", "PeriodKind", + "RecomputeResult", "Rules", "Severity", "SignForm", @@ -86,6 +96,11 @@ __all__ = [ "StatementLine", "StatementValue", "StatementWarning", + "StoredColumn", + "StoredForm", + "StoredLine", + "StoredValue", + "StoredWarning", "StatusInput", "StatusLine", "StatusValue", @@ -110,6 +125,7 @@ __all__ = [ "parse_code", "parse_date", "parse_header", + "recompute", "resolve_relative", "sheet_joins", "statements_version", diff --git a/arm_statements/recompute.py b/arm_statements/recompute.py new file mode 100644 index 0000000..8e2b335 --- /dev/null +++ b/arm_statements/recompute.py @@ -0,0 +1,177 @@ +"""Точка входа пересчёта формы по сохранённым данным (спека «Точка входа пакета для пересчёта…»). + +Ядро собирает `StoredForm` из своих строк БД; сервисы функцией не пользуются. Пересчёт не меняет `line_status`, +суммы и коды — только выводит признаки (Р-34: подтверждение кода — `code_fixed`, уверенность кода не меняется). +""" + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import date +from decimal import Decimal + +from arm_statements.checks import checks_from +from arm_statements.evaluation import EvalLine, EvalValue +from arm_statements.loader import Rules, default_rules +from arm_statements.model import ( + DocStatus, + DocumentType, + Edition, + FormVariant, + LineStatus, + PeriodKind, + Severity, + StatementChecks, +) +from arm_statements.review import evaluate +from arm_statements.status import StatusInput, StatusLine, StatusValue, document_status +from arm_statements.structure import edition_by_date + + +@dataclass(frozen=True, slots=True) +class StoredColumn: + key: str + column_index: int | None + period_date: date | None + period_kind: PeriodKind + + +@dataclass(frozen=True, slots=True) +class StoredValue: + column_key: str + amount: Decimal | None + text: str | None # source_text; у значения правки None + confidence: float | None + is_recognized: bool + value_fixed: bool # is_modified или is_confirmed + + +@dataclass(frozen=True, slots=True) +class StoredLine: + key: str + row_index: int | None # None — строка правки + line_status: LineStatus + line_code: str | None + line_code_confidence: float | None + line_name: str | None + code_fixed: bool # line_code_modified или is_confirmed строки + parent_key: str | None + values: tuple[StoredValue, ...] + + +@dataclass(frozen=True, slots=True) +class StoredWarning: + code: str + severity: Severity + + +@dataclass(frozen=True, slots=True) +class StoredForm: + document_type: DocumentType + effective_date: date | None + has_organization_or_inn: bool + period_known: bool + needs_review_fields: tuple[str, ...] + accepted: bool + warnings: tuple[StoredWarning, ...] + columns: tuple[StoredColumn, ...] + lines: tuple[StoredLine, ...] + + +@dataclass(frozen=True, slots=True) +class RecomputeResult: + form_variant: FormVariant | None + edition: Edition | None + checks: StatementChecks + line_needs_review: Mapping[str, bool] + value_needs_review: Mapping[tuple[str, str], bool] + detail_parents: Mapping[str, str | None] + status: DocStatus + + +def _sort_key(line: StoredLine) -> tuple[bool, int]: + return (line.row_index is None, line.row_index or 0) + + +def _eval_line(line: StoredLine) -> EvalLine: + return EvalLine( + key=line.key, + order=line.row_index, + line_status=line.line_status, + line_code=line.line_code, + code_confidence=line.line_code_confidence, + code_fixed=line.code_fixed, + values=tuple( + EvalValue(v.column_key, v.amount, v.text, v.confidence, v.is_recognized, fixed=v.value_fixed) + for v in line.values + ), + ) + + +def _detail_parents(lines: Sequence[StoredLine]) -> dict[str, str | None]: + """Ближайшая выше по `row_index` строка `ok` с `row_index`; у `detail` без `row_index` и без такой — `None`.""" + parents: dict[str, str | None] = {} + last_ok: str | None = None + for line in lines: # уже отсортированы: строки правки в конце + if line.row_index is None: + if line.line_status is LineStatus.DETAIL: + parents[line.key] = None + continue + if line.line_status is LineStatus.DETAIL: + parents[line.key] = last_ok + elif line.line_status is LineStatus.OK: + last_ok = line.key + return parents + + +def recompute(form: StoredForm, rules: Rules | None = None) -> RecomputeResult: + """Шаги 6–8 по сохранённым данным: вариант, редакция, §7, `needs_review`, родители `detail`, статус.""" + rules = rules if rules is not None else default_rules() + if form.document_type is DocumentType.DISCLOSURE: + return RecomputeResult( + form_variant=None, + edition=edition_by_date(form.effective_date), + checks=StatementChecks((), ()), + line_needs_review={}, + value_needs_review={}, + detail_parents={}, + status=DocStatus.DONE, + ) + lines = sorted(form.lines, key=_sort_key) + evaluation = evaluate( + [_eval_line(line) for line in lines], + [column.key for column in form.columns], + form.document_type, + form.effective_date, + rules, + accepted=form.accepted, + ) + checks = checks_from(evaluation.outcomes, {column.key: column.column_index for column in form.columns}) + status = document_status( + StatusInput( + document_type=form.document_type, + layout_found=any(line.row_index is not None for line in lines), + has_organization_or_inn=form.has_organization_or_inn, + period_known=form.period_known, + needs_review_fields=form.needs_review_fields, + lines=tuple( + StatusLine(line.line_status, evaluation.line_needs_review.get(line.key, False)) for line in lines + ), + values=tuple( + StatusValue(line.line_status, evaluation.value_needs_review.get((line.key, v.column_key), False)) + for line in lines + for v in line.values + ), + hard_failures=evaluation.hard_failures, + accepted=form.accepted, + warning_severities=tuple(w.severity for w in form.warnings), + ) + ) + return RecomputeResult( + form_variant=evaluation.variant, + edition=evaluation.edition, + checks=checks, + line_needs_review=evaluation.line_needs_review, + value_needs_review=evaluation.value_needs_review, + detail_parents=_detail_parents(lines), + status=status, + ) diff --git a/arm_statements/tests/test_recompute.py b/arm_statements/tests/test_recompute.py new file mode 100644 index 0000000..9564b8a --- /dev/null +++ b/arm_statements/tests/test_recompute.py @@ -0,0 +1,87 @@ +from dataclasses import replace +from datetime import date +from decimal import Decimal as D + +from arm_statements import StoredColumn, StoredForm, StoredLine, StoredValue, choose_edition, recompute +from arm_statements.loader import Rules +from arm_statements.model import DocStatus, DocumentType, Edition, FormVariant, LineStatus, PeriodKind + +OK, DETAIL = LineStatus.OK, LineStatus.DETAIL +COL = StoredColumn("c1", 2, date(2025, 9, 30), PeriodKind.ABSOLUTE) + + +def sl( # noqa: PLR0913 -- конструктор тестовых строк + key: str, + row: int | None, + code: str | None, + amount: str | None, + *, + status: LineStatus = OK, + conf: float = 100.0, + code_fixed: bool = False, + value_conf: float = 100.0, + value_fixed: bool = False, + recognized: bool = True, +) -> StoredLine: + value = StoredValue("c1", None if amount is None else D(amount), amount, value_conf, recognized, value_fixed) + return StoredLine(key, row, status, code, conf, None, code_fixed, None, (value,)) + + +def form(*lines: StoredLine, document_type: DocumentType = DocumentType.BALANCE) -> StoredForm: + return StoredForm(document_type, date(2025, 9, 30), True, True, (), False, (), (COL,), tuple(lines)) + + +def test_recompute_after_edit(rules: Rules) -> None: + result = recompute( + form( + sl("l1210", 0, "1210", "100"), + sl("l1230", 1, "1230", "300", value_conf=60.0, value_fixed=True), + sl("l1250", 2, "1250", "50", conf=40.0, code_fixed=True), + sl("l1260", None, "1260", "50"), + sl("ldet", 3, None, "7", status=DETAIL, value_conf=60.0), + sl("l1200", 4, "1200", "500"), + ), + rules, + ) + # 1200 = 100 + 300 + 50 + 50 (с 1250 и строкой правки 1260) — сходится; 1600 нет — «пустая цель» + assert [(f.relation, f.column_key, f.column_index) for f in result.checks.relations_failed] == [ + ("1600 = 1100 + 1200", "c1", 2) + ] + assert result.value_needs_review[("l1230", "c1")] is False + assert result.value_needs_review[("ldet", "c1")] is False + assert result.line_needs_review["l1250"] is False + assert result.detail_parents == {"ldet": "l1250"} + assert (result.edition, result.form_variant, result.status) == ( + Edition.FSBU4, + FormVariant.FULL, + DocStatus.NEEDS_CHECK, + ) + + +def test_code_change_switches_variant(rules: Rules) -> None: + simplified = form( + sl("a", 0, "1150", "50"), sl("b", 1, "1600", "50"), sl("c", 2, "1300", "50"), sl("d", 3, "1700", "50") + ) + assert recompute(simplified, rules).form_variant is FormVariant.SIMPLIFIED + edited = form( + sl("a", 0, "1100", "50", code_fixed=True), + sl("b", 1, "1600", "50"), + sl("c", 2, "1300", "50"), + sl("d", 3, "1700", "50"), + ) + result = recompute(edited, rules) + assert result.form_variant is FormVariant.FULL + assert result.status is DocStatus.NEEDS_CHECK + + +def test_edition_equals_choose_edition(rules: Rules) -> None: + stored = form(sl("a", 0, "1110", "10"), sl("b", 1, "1120", "5", conf=60.0), sl("c", 2, "1130", "5")) + pairs = [("1110", True), ("1120", False), ("1130", True)] + expected = choose_edition(DocumentType.BALANCE, pairs, date(2025, 9, 30), rules) + assert recompute(stored, rules).edition is expected is Edition.RAS_66N + + +def test_accepted_and_empty_edit_line(rules: Rules) -> None: + broken = form(sl("a", 0, "1600", "100"), sl("b", 1, "1700", "90"), sl("e", None, "1220", None)) + assert recompute(broken, rules).status is DocStatus.NEEDS_CHECK + assert recompute(replace(broken, accepted=True), rules).status is DocStatus.DONE