feat: точка входа пересчёта формы по сохранённым данным (правки, подтверждения, строки правки)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 14:04:19 +04:00
parent 29198ddc44
commit 9e28213ce6
3 changed files with 282 additions and 2 deletions

View File

@ -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",

177
arm_statements/recompute.py Normal file
View File

@ -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,
)

View File

@ -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