From 55b60f58b2d82604e240d2d051374e4e5db6f806 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 16:59:02 +0400 Subject: [PATCH] =?UTF-8?q?feat(statements):=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=81=20=D0=BA=D0=BE=D0=B4=D0=BE=D0=BC,=20?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20Decimal,=20?= =?UTF-8?q?=D0=B4=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=B0=D1=82=D1=8B=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D0=BE=D0=B2,=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE?= =?UTF-8?q?=20=D0=B5=D0=B4=D0=B8=D0=BD=D0=B8=D1=86=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #14 Co-Authored-By: Claude Haiku 4.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- api/src/parser/statements/lines.py | 116 ++++++++++++++++++ .../unit/parser/statements/test_lines.py | 92 ++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 api/src/parser/statements/lines.py create mode 100644 api/tests/unit/parser/statements/test_lines.py diff --git a/api/src/parser/statements/lines.py b/api/src/parser/statements/lines.py new file mode 100644 index 0000000..589fd4a --- /dev/null +++ b/api/src/parser/statements/lines.py @@ -0,0 +1,116 @@ +"""Строки формы с кодом и их значения (design D-6). + +Строка с кодом в колонке кодов → `StatementLine`; строки без кода не отдаются (ASSUMPTION(OQ-36)), но если среди них +есть суммы — одно предупреждение. Повтор кода — остаётся первая строка, код в `duplicates`. Числа — `numbers.to_money` +(Decimal, скобки → минус, прочерк → null); нечисловой текст — `string_value`, `is_recognized = false`. Единицы не +нормализуются: при «тыс.» в единицах и доле значений с дробной частью ≥ `FRACTIONAL_SHARE` — предупреждение. +""" + +from dataclasses import dataclass, field + +from src.contract.result import StatementLine, StatementValue +from src.parser.grid import Sheet +from src.parser.numbers import to_money +from src.parser.rules import Rules, normalize_text +from src.parser.statements.columns import TableLayout, cell_label, line_code + +__all__ = ["FRACTIONAL_SHARE", "LinesResult", "collect_lines"] + +_FULL_CONFIDENCE = 100 +FRACTIONAL_SHARE = 0.1 +_THOUSANDS = "тыс" + + +@dataclass +class LinesResult: + lines: list[StatementLine] = field(default_factory=list) + duplicates: list[str] = field(default_factory=list) + skipped_with_sums: int = 0 + fractional_values: int = 0 + numeric_values: int = 0 + warnings: list[str] = field(default_factory=list) + + +def _value(sheet: Sheet, row: int, col: int, column_index: int) -> tuple[StatementValue, bool]: + """Значение ячейки и признак «число с дробной частью».""" + cell = sheet.cell(*sheet.merged_origin(row, col)) + parsed = to_money(cell) + if parsed.value is not None: + has_fractional = parsed.value % 1 != 0 + value = StatementValue( + column_index=column_index, + amount=parsed.value, + confidence=_FULL_CONFIDENCE, + is_recognized=True, + ) + return value, has_fractional + if parsed.warning is None: # пусто или прочерк + value = StatementValue( + column_index=column_index, + amount=None, + confidence=_FULL_CONFIDENCE, + is_recognized=True, + ) + return value, False + value = StatementValue( + column_index=column_index, + amount=None, + string_value=cell.text, + confidence=_FULL_CONFIDENCE, + is_recognized=False, + ) + return value, False + + +def collect_lines( # noqa: PLR0913 + sheet: Sheet, + rules: Rules, + layout: TableLayout, + first_row: int, + last_row: int, + *, + units: str | None = None, +) -> LinesResult: + result = LinesResult() + seen: set[str] = set() + for row in range(first_row, last_row + 1): + if sheet.is_row_empty(row): + continue + code = line_code(sheet.cell(row, layout.code_col), rules) + if code is None: + if any(to_money(sheet.cell(row, column.col)).value is not None for column in layout.periods): + result.skipped_with_sums += 1 + continue + if code in seen: + result.duplicates.append(code) + continue + seen.add(code) + values: list[StatementValue] = [] + for index, column in enumerate(layout.periods): + value, fractional = _value(sheet, row, column.col, index) + values.append(value) + if value.amount is not None: + result.numeric_values += 1 + result.fractional_values += int(fractional) + name = cell_label(sheet, row, layout.name_col) if layout.name_col is not None else "" + result.lines.append( + StatementLine( + line_code=code, + line_code_confidence=_FULL_CONFIDENCE, + line_name=name, + line_name_confidence=_FULL_CONFIDENCE, + values=values, + ) + ) + if result.skipped_with_sums: + result.warnings.append(f"строк без кода с суммами пропущено: {result.skipped_with_sums}") + if ( + units + and _THOUSANDS in normalize_text(units) + and result.numeric_values + and result.fractional_values / result.numeric_values >= FRACTIONAL_SHARE + ): + result.warnings.append( + f"значений с дробной частью при единице тыс. руб.: {result.fractional_values} из {result.numeric_values}" + ) + return result diff --git a/api/tests/unit/parser/statements/test_lines.py b/api/tests/unit/parser/statements/test_lines.py new file mode 100644 index 0000000..7456135 --- /dev/null +++ b/api/tests/unit/parser/statements/test_lines.py @@ -0,0 +1,92 @@ +"""Строки с кодом и значения (design D-6; spec «Строки с кодом и значения»).""" + +from decimal import Decimal + +import pytest +from gridbuilder import sheet_from_rows + +from src.parser.rules import Rules, load_rules +from src.parser.statements.columns import find_layout +from src.parser.statements.lines import collect_lines + +_FULL_CONFIDENCE = 100 +_FRACTIONAL_VALUES_COUNT = 4 +_NUMERIC_VALUES_COUNT = 9 +_SKIPPED_WITH_SUMS_COUNT = 1 + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +ROWS = [ + ["Пояснения", "Наименование показателя", "Код", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."], + [None, "АКТИВ"], + [None, "Нематериальные активы", "1110", Decimal("5971264.14"), 27405634], + [None, "Нематериальные активы\nв организации", "11101", Decimal("5852464.14"), "27 214 614,65"], + [None, None, None, 0, 0], + [None, "в том числе:"], + [None, "Финансовые вложения", "1170", None, 10], + [None, "Прочие", "1190", "–", "(2 100)"], + [None, "Запасы", "1210", "н/д", 5], + [None, "Запасы", "1210", 1, 1], + [None, "Итого по разделу I", "1100", Decimal("11823728.28"), 27405644], +] + + +def test_lines_values_and_skips(rules: Rules) -> None: + sheet = sheet_from_rows(ROWS) + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows) + + codes = [line.line_code for line in result.lines] + assert codes == ["1110", "11101", "1170", "1190", "1210", "1100"] + by_code = {line.line_code: line for line in result.lines} + assert by_code["11101"].line_name == "Нематериальные активы в организации" + assert [value.amount for value in by_code["11101"].values] == [Decimal("5852464.14"), Decimal("27214614.65")] + assert by_code["1170"].values[0].amount is None and by_code["1170"].values[0].is_recognized is True + assert by_code["1190"].values[0].amount is None and by_code["1190"].values[1].amount == Decimal("-2100.00") + unrecognised = by_code["1210"].values[0] + assert unrecognised.amount is None and unrecognised.string_value == "н/д" and unrecognised.is_recognized is False + assert all(value.confidence == _FULL_CONFIDENCE for line in result.lines for value in line.values) + assert all( + line.line_code_confidence == _FULL_CONFIDENCE and line.line_name_confidence == _FULL_CONFIDENCE + for line in result.lines + ) + assert [value.column_index for value in by_code["1110"].values] == [0, 1] + assert result.duplicates == ["1210"] and by_code["1210"].values[1].amount == Decimal("5.00") + assert result.skipped_with_sums == _SKIPPED_WITH_SUMS_COUNT # строка нулей без кода + assert result.fractional_values == _FRACTIONAL_VALUES_COUNT and result.numeric_values == _NUMERIC_VALUES_COUNT + + +def test_fractional_warning_when_units_thousands(rules: Rules) -> None: + sheet = sheet_from_rows(ROWS) + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows, units="в тыс. рублей") + + expected_warning = ( + f"значений с дробной частью при единице тыс. руб.: {_FRACTIONAL_VALUES_COUNT} из {_NUMERIC_VALUES_COUNT}" + ) + assert any(warning.startswith(expected_warning) for warning in result.warnings) + assert any("строк без кода с суммами пропущено: 1" in warning for warning in result.warnings) + + +def test_no_fractional_warning_without_thousands(rules: Rules) -> None: + sheet = sheet_from_rows(ROWS) + layout = find_layout(sheet, rules, 1, sheet.n_rows) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, sheet.n_rows, units="руб.") + + assert not any("дробной частью" in warning for warning in result.warnings) + + +def test_line_without_name_column(rules: Rules) -> None: + sheet = sheet_from_rows([["Код", "На 31.12.2024"], ["1150", 5], ["1210", 6], ["1600", 11]]) + layout = find_layout(sheet, rules, 1, 4) + + result = collect_lines(sheet, rules, layout, layout.first_data_row, 4) + + assert [line.line_name for line in result.lines] == ["", "", ""]