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:
parent
91e96795d5
commit
0c4dc6d221
88
arm_statements/lines.py
Normal file
88
arm_statements/lines.py
Normal 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)]
|
||||
63
arm_statements/tests/test_lines.py
Normal file
63
arm_statements/tests/test_lines.py
Normal 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) == []
|
||||
Reference in New Issue
Block a user