From 0c4dc6d2217bc97f1de001abb4dc0de231c2210e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 13:47:37 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=88=D0=B0=D0=B3=D0=B8=204=E2=80=935?= =?UTF-8?q?=20=E2=80=94=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8=20=D1=84?= =?UTF-8?q?=D0=BE=D1=80=D0=BC=D1=8B=20=D1=81=20=D1=81=D1=8B=D1=80=D1=8B?= =?UTF-8?q?=D0=BC=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=BE=D0=BC=20=D0=B8=20?= =?UTF-8?q?=D1=83=D0=B2=D0=B5=D1=80=D0=B5=D0=BD=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=D1=8E,=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80?= =?UTF-8?q?=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8=D1=86=20=D0=B8=20=D0=BC=D0=B0=D1=81=D1=88=D1=82?= =?UTF-8?q?=D0=B0=D0=B1=D0=B0=20xlsb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- arm_statements/lines.py | 88 ++++++++++++++++++++++++++++++ arm_statements/tests/test_lines.py | 63 +++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 arm_statements/lines.py create mode 100644 arm_statements/tests/test_lines.py diff --git a/arm_statements/lines.py b/arm_statements/lines.py new file mode 100644 index 0000000..0702806 --- /dev/null +++ b/arm_statements/lines.py @@ -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)] diff --git a/arm_statements/tests/test_lines.py b/arm_statements/tests/test_lines.py new file mode 100644 index 0000000..1c868e8 --- /dev/null +++ b/arm_statements/tests/test_lines.py @@ -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) == []