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
|
Публичный API — только имена `__all__` (раздел «Публичный API пакета» плана 1). Имена задачи 19
|
||||||
(`recompute`, `Stored*`, `to_contract`, `warning_to_contract`, `status_input_from_contract`) добавляются ими.
|
(`to_contract`, `warning_to_contract`, `status_input_from_contract`) добавляются ими.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from arm_statements.amounts import parse_amount
|
from arm_statements.amounts import parse_amount
|
||||||
@ -43,6 +43,15 @@ from arm_statements.model import (
|
|||||||
StatementWarning,
|
StatementWarning,
|
||||||
)
|
)
|
||||||
from arm_statements.pipeline import normalize, normalize_candidate
|
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.serialize import candidate_from_dict, to_jsonable
|
||||||
from arm_statements.status import (
|
from arm_statements.status import (
|
||||||
StatusInput,
|
StatusInput,
|
||||||
@ -75,6 +84,7 @@ __all__ = [
|
|||||||
"LineStatus",
|
"LineStatus",
|
||||||
"NormalizationResult",
|
"NormalizationResult",
|
||||||
"PeriodKind",
|
"PeriodKind",
|
||||||
|
"RecomputeResult",
|
||||||
"Rules",
|
"Rules",
|
||||||
"Severity",
|
"Severity",
|
||||||
"SignForm",
|
"SignForm",
|
||||||
@ -86,6 +96,11 @@ __all__ = [
|
|||||||
"StatementLine",
|
"StatementLine",
|
||||||
"StatementValue",
|
"StatementValue",
|
||||||
"StatementWarning",
|
"StatementWarning",
|
||||||
|
"StoredColumn",
|
||||||
|
"StoredForm",
|
||||||
|
"StoredLine",
|
||||||
|
"StoredValue",
|
||||||
|
"StoredWarning",
|
||||||
"StatusInput",
|
"StatusInput",
|
||||||
"StatusLine",
|
"StatusLine",
|
||||||
"StatusValue",
|
"StatusValue",
|
||||||
@ -110,6 +125,7 @@ __all__ = [
|
|||||||
"parse_code",
|
"parse_code",
|
||||||
"parse_date",
|
"parse_date",
|
||||||
"parse_header",
|
"parse_header",
|
||||||
|
"recompute",
|
||||||
"resolve_relative",
|
"resolve_relative",
|
||||||
"sheet_joins",
|
"sheet_joins",
|
||||||
"statements_version",
|
"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
|
||||||
Reference in New Issue
Block a user