From 33b32cbad6e1f8e203d8eb30de32d49f3120fbee Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 12:00:03 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D1=80=D0=BE=D0=BA=D0=B8-=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D0=B8=20=D0=B2?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=BA=D0=B0=D1=81=D0=BA=D0=B0=D0=B4=D0=B0=20?= =?UTF-8?q?=D0=B8=D0=B5=D1=80=D0=B0=D1=80=D1=85=D0=B8=D0=B8=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- api/src/parser/indicators.py | 28 +++++++++++- api/src/parser/inn_rows.py | 7 +-- api/src/parser/osv.py | 4 +- api/tests/unit/parser/test_indicators.py | 57 +++++++++++++++++++++++- 4 files changed, 87 insertions(+), 9 deletions(-) diff --git a/api/src/parser/indicators.py b/api/src/parser/indicators.py index af351b8..2141dd9 100644 --- a/api/src/parser/indicators.py +++ b/api/src/parser/indicators.py @@ -1,13 +1,16 @@ """Строки-показатели ОСВ (§1.3, AURORA-1613, AURORA-1661): метка вида суммы строки и привязка к строке данных.""" +from collections.abc import Sequence from dataclasses import dataclass from src.contract.common import DataType +from src.parser.deadline import Deadline from src.parser.detect import TableHeader from src.parser.grid import Sheet +from src.parser.hierarchy import ROOT_LEVEL, BodyRow, Levels, resolve_levels from src.parser.rules import Rules, first_match, normalize_text -__all__ = ["RowLabel", "column_label", "is_indicator_total", "read_label", "row_data_type"] +__all__ = ["RowLabel", "column_label", "is_indicator_total", "levels_with_indicators", "read_label", "row_data_type"] @dataclass(frozen=True) @@ -53,3 +56,26 @@ def is_indicator_total(sheet: Sheet, row: int, header: TableHeader, rules: Rules """Итоговая строка-показатель за «Итого»: метка колонки «Показатели» не «БУ».""" label = column_label(sheet, row, header) return bool(label) and first_match(rules.data_types.indicator_column, label) != "BU" + + +def levels_with_indicators(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels: + """Каскад иерархии без строк-показателей; показатель — ребёнок предыдущей строки данных, на уровень глубже + (§1.3): так строки НУ/Кол./«В валюте» на одном outline-уровне со своей строкой не ломают дерево строк БУ. + + Итоговые строки-показатели и показатель без строки данных выше — уровень 0 без родителя. + """ + levels = resolve_levels([item for item in rows if not item.indicator], total_row, deadline) + by_row: dict[int, int] = {} + parent: dict[int, int | None] = {} + previous: int | None = None + for item in rows: + if not item.indicator: + by_row[item.row], parent[item.row] = levels.by_row[item.row], levels.parent[item.row] + if total_row is None or item.row < total_row: + previous = item.row + continue + if previous is None or (total_row is not None and item.row >= total_row): + by_row[item.row], parent[item.row] = ROOT_LEVEL, None + else: + by_row[item.row], parent[item.row] = by_row[previous] + 1, previous + return Levels(by_row=by_row, parent=parent, strategy=levels.strategy) diff --git a/api/src/parser/inn_rows.py b/api/src/parser/inn_rows.py index 3ecc22b..42ceb5c 100644 --- a/api/src/parser/inn_rows.py +++ b/api/src/parser/inn_rows.py @@ -18,7 +18,6 @@ from decimal import Decimal from typing import Literal from src.contract.result import OsvGroup -from src.parser.classify import row_data_type from src.parser.grid import Cell, Sheet from src.parser.header import InnHint from src.parser.hierarchy import BodyRow, Levels @@ -104,7 +103,6 @@ def _build( # noqa: PLR0913, PLR0917 - всё нужное для вида ст hint: InnHint | None, rules: Rules, ) -> _Tree: - currency = frozenset(rules.data_types.currency_codes) children: dict[int | None, list[int]] = {} for item in rows: children.setdefault(levels.parent[item.row], []).append(item.row) @@ -112,7 +110,7 @@ def _build( # noqa: PLR0913, PLR0917 - всё нужное для вида ст for siblings in children.values(): tree.position.update((row, index) for index, row in enumerate(siblings)) for item in rows: - tree.kind[item.row] = _kind(item, total_row, sheet, hint, rules, currency, tree) + tree.kind[item.row] = _kind(item, total_row, sheet, hint, rules, tree) if hint is not None and hint.direction == "below": # «ИНН не указан» (R-1 #32): с подсказкой «под» пустая строка-ребёнок наименования — строка ИНН без значения for item in rows: @@ -129,14 +127,13 @@ def _kind( # noqa: PLR0913, PLR0917 - вид строки зависит от sheet: Sheet, hint: InnHint | None, rules: Rules, - currency: frozenset[str], tree: _Tree, ) -> Kind: if total_row is not None and item.row >= total_row: return "total" if item.is_account: return "account" - if row_data_type(item.text, rules, currency) is not None: + if item.indicator: return "indicator" found = row_inn(_label_cell(sheet, item), rules) if found is None: diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index d4d1cc2..74515a9 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -19,7 +19,7 @@ from src.parser.detect import TableHeader, find_table_header from src.parser.errors import InvalidFormatError from src.parser.grid import Sheet, column_letter from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_levels, parse_doc_header -from src.parser.hierarchy import resolve_levels +from src.parser.indicators import levels_with_indicators from src.parser.inn_rows import InnGlue, glue_inn_rows from src.parser.osv_warnings import ( OSV_HEADER, @@ -181,7 +181,7 @@ class OsvParser: step = "строки таблицы" body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline) step = "иерархия строк" - levels = resolve_levels(body, total_row, deadline) + levels = levels_with_indicators(body, total_row, deadline) step = "строки ИНН" glue = glue_inn_rows( body, diff --git a/api/tests/unit/parser/test_indicators.py b/api/tests/unit/parser/test_indicators.py index 43eb0ef..f11e505 100644 --- a/api/tests/unit/parser/test_indicators.py +++ b/api/tests/unit/parser/test_indicators.py @@ -1,11 +1,15 @@ """Метки строк-показателей (§1.3, #58): колонка «Показатели» и текст строки.""" +from decimal import Decimal + import pytest from gridbuilder import L, M, at, sheet_from_rows +from src.parser.deadline import Deadline from src.parser.detect import TableHeader, find_table_header from src.parser.grid import Sheet -from src.parser.indicators import RowLabel, is_indicator_total, read_label +from src.parser.hierarchy import BodyRow +from src.parser.indicators import RowLabel, is_indicator_total, levels_with_indicators, read_label from src.parser.rules import Rules, load_rules TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г." @@ -80,3 +84,54 @@ def test_is_indicator_total(rules: Rules) -> None: assert is_indicator_total(sheet, FIRST_ROW, header, rules) assert not is_indicator_total(sheet, FIRST_ROW + 1, header, rules) assert not is_indicator_total(sheet, FIRST_ROW + 2, header, rules) + + +# --- Иерархия: показатели вне каскада --- + + +def _row(row: int, text: str, outline: int, *, indicator: bool = False) -> BodyRow: + return BodyRow( + row=row, + text=text, + is_account=text.isdigit(), + account_rank=1 if text.isdigit() else None, + bold=False, + indent=0, + outline=outline, + sums=(Decimal(1),) * 6, + indicator=indicator, + ) + + +def test_indicators_are_children_of_previous_data_row_outside_cascade() -> None: + rows = [ + _row(10, "60", 0), + _row(11, "НУ", 0, indicator=True), + _row(12, "Объект", 1), + _row(13, "НУ", 1, indicator=True), + _row(14, "Итого", 0), + _row(15, "НУ", 0, indicator=True), + ] + levels = levels_with_indicators(rows, 14, Deadline(3600)) + assert levels.strategy == "outline" + assert levels.by_row == {10: 0, 11: 1, 12: 1, 13: 2, 14: 0, 15: 0} + assert levels.parent == {10: None, 11: 10, 12: 10, 13: 12, 14: None, 15: None} + + +def test_indicator_first_in_table_is_root() -> None: + rows = [_row(10, "НУ", 0, indicator=True), _row(11, "60", 0)] + levels = levels_with_indicators(rows, None, Deadline(3600)) + assert levels.by_row[10] == 0 + assert levels.parent[10] is None + + +def test_levels_keep_sheet_row_order() -> None: + rows = [ + _row(10, "60", 0), + _row(11, "НУ", 0, indicator=True), + _row(12, "Объект", 1), + _row(13, "НУ", 1, indicator=True), + ] + levels = levels_with_indicators(rows, None, Deadline(3600)) + assert list(levels.by_row) == [10, 11, 12, 13] + assert list(levels.parent) == [10, 11, 12, 13]