feat: шаги 4–5 — строки формы с сырым текстом и уверенностью, предупреждения единиц и масштаба xlsb

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 13:47:37 +04:00
parent 91e96795d5
commit 0c4dc6d221
2 changed files with 151 additions and 0 deletions

88
arm_statements/lines.py Normal file
View File

@ -0,0 +1,88 @@
"""Шаги 4–5: значения ячеек в строки формы и предупреждения единиц измерения."""
from collections.abc import Sequence
from decimal import Decimal
from arm_statements.amounts import build_value
from arm_statements.issues import WarningCode, make_warning
from arm_statements.loader import Rules
from arm_statements.model import (
LineStatus,
Source,
StatementHeader,
StatementLine,
StatementValue,
StatementWarning,
)
from arm_statements.rows import RawLine
CODE_CONFIDENCE_CAP = 99.0
_PERCENT = 100
def _code_confidence(raw: RawLine) -> float | None:
if raw.code_cell is None:
return None
confidence = raw.code_cell.confidence
return min(confidence, CODE_CONFIDENCE_CAP) if raw.code_fixed else confidence
def build_lines(
raw: Sequence[RawLine], source: Source, rules: Rules
) -> tuple[list[StatementLine], list[StatementWarning]]:
"""Строки формы со значениями; `VALUE_OUT_OF_RANGE` (`info`) на каждое значение вне диапазона."""
lines: list[StatementLine] = []
warnings: list[StatementWarning] = []
for item in raw:
values: list[StatementValue] = []
for column_index, cell in item.cells:
built = build_value(cell, column_index, source, rules, split_part=item.split)
values.append(built.value)
if built.out_of_range and cell is not None:
message = f"значение ячейки {cell.ref} вне диапазона numeric(18,2)"
warnings.append(
make_warning(
WarningCode.VALUE_OUT_OF_RANGE, message, rules, line_refs=(cell.ref,), column_index=column_index
)
)
lines.append(
StatementLine(
row_index=item.row_index,
ref=item.ref,
line_status=item.line_status,
line_code=item.code if item.line_status is LineStatus.OK else None,
line_code_text=item.code_cell.text if item.code_cell is not None else None,
line_code_confidence=_code_confidence(item),
line_name=item.name.text if item.name is not None else None,
line_name_confidence=item.name.confidence if item.name is not None else None,
parent_ref=item.parent_ref,
needs_review=False,
values=tuple(values),
)
)
return lines, warnings
def _has_fraction(amount: Decimal) -> bool:
return amount != amount.to_integral_value()
def units_warnings(
lines: Sequence[StatementLine],
header: StatementHeader,
adapter_warnings: Sequence[StatementWarning],
rules: Rules,
) -> list[StatementWarning]:
"""Единицы «тыс. руб.» и доля дробных не ниже порога: `FRACTIONAL_IN_THOUSANDS` / `XLSB_SCALE_SUSPECT`."""
if header.units.value != rules.units.thousands:
return []
amounts = [v.amount for line in lines for v in line.values if v.amount is not None]
if not amounts:
return []
fractional = sum(1 for amount in amounts if _has_fraction(amount))
if fractional * _PERCENT < rules.units.fractional_share_percent * len(amounts):
return []
no_formats = any(w.code == WarningCode.XLSB_NO_FORMATS for w in adapter_warnings)
code = WarningCode.XLSB_SCALE_SUSPECT if no_formats else WarningCode.FRACTIONAL_IN_THOUSANDS
message = f"единицы «{rules.units.thousands}», но {fractional} из {len(amounts)} значений с дробной частью"
return [make_warning(code, message, rules)]

View File

@ -0,0 +1,63 @@
from decimal import Decimal as D
from arm_statements.header import parse_header
from arm_statements.issues import make_warning
from arm_statements.lines import build_lines, units_warnings
from arm_statements.loader import Rules
from arm_statements.model import FormCandidate, Severity, StatementLine, StatementWarning
from arm_statements.rows import collect_raw_lines
from arm_statements.segments import find_layout
from arm_statements.tests.builders import excel, hdr, ocr
HEAD = ["Наименование показателя", "Код", "На 30.09.2025"]
CODE_CONFIDENCE = 99.0
NAME_CONFIDENCE = 71.0
def statement_lines(cand: FormCandidate, rules: Rules) -> tuple[list[StatementLine], list[StatementWarning]]:
layout = find_layout(cand, parse_header(hdr(), rules), rules)
raw, _ = collect_raw_lines(cand, layout, rules)
return build_lines(raw, cand.source, rules)
def test_ocr_name_and_code_confidence(rules: Rules) -> None:
cand = ocr([HEAD, [("Дебиторская задолжен-ность", 71.0), "1230", "5"], ["Итого", "I1О0", "5"]])
lines, _ = statement_lines(cand, rules)
assert (lines[0].line_name, lines[0].line_name_confidence) == ("Дебиторская задолжен-ность", NAME_CONFIDENCE)
assert (lines[1].line_code, lines[1].line_code_text, lines[1].line_code_confidence) == (
"1100",
"I1О0",
CODE_CONFIDENCE,
)
assert [v.column_index for v in lines[0].values] == [2]
def test_value_out_of_range_warning(rules: Rules) -> None:
cand = excel([("Баланс", [["Наименование", "Код", "На 30.09.2025"], ["Запасы", "1210", D("123456789012345678")]])])
lines, warnings = statement_lines(cand, rules)
assert lines[0].values[0].amount is None
assert [(w.code, w.severity, w.line_refs, w.column_index) for w in warnings] == [
("VALUE_OUT_OF_RANGE", Severity.INFO, ("Баланс!C2",), 2)
]
def test_excel_code_ref_and_clean_confidence(rules: Rules) -> None:
cand = excel([("Баланс", [["Наименование", "Код", "На 30.09.2025"], ["Запасы", "1210", D("5")]])])
lines, warnings = statement_lines(cand, rules)
assert (lines[0].ref, lines[0].line_code, lines[0].line_code_text) == ("Баланс!B2", "1210", "1210")
assert (lines[0].line_code_confidence, lines[0].needs_review, warnings) == (100.0, False, [])
def test_fractional_in_thousands(rules: Rules) -> None:
pairs = [("1210", "1.5"), ("1220", "2.25"), ("1230", "3"), ("1240", "4"), ("1200", "10.75")]
title: list[str | D | None] = ["Наименование", "Код", "На 30.09.2025"]
body: list[list[str | D | None]] = [[f"Строка {i}", c, D(v)] for i, (c, v) in enumerate(pairs)]
rows = [title, *body]
cand = excel([("Баланс", rows)])
lines, _ = statement_lines(cand, rules)
head = parse_header(hdr(units="тыс. руб."), rules)
assert [w.code for w in units_warnings(lines, head, (), rules)] == ["FRACTIONAL_IN_THOUSANDS"]
xlsb = make_warning("XLSB_NO_FORMATS", "формат чисел xlsb не читается", rules)
(scale,) = units_warnings(lines, head, (xlsb,), rules)
assert (scale.code, scale.severity) == ("XLSB_SCALE_SUSPECT", Severity.ERROR)
assert units_warnings(lines, parse_header(hdr(units="руб."), rules), (), rules) == []