feat: точка входа пересчёта формы по сохранённым данным (правки, подтверждения, строки правки)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
parent
29198ddc44
commit
9e28213ce6
@ -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
177
arm_statements/recompute.py
Normal 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,
|
||||
)
|
||||
87
arm_statements/tests/test_recompute.py
Normal file
87
arm_statements/tests/test_recompute.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user