Строки-показатели вне каскада иерархии (#58)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 12:00:03 +04:00
parent ef07ced609
commit 33b32cbad6
4 changed files with 87 additions and 9 deletions

View File

@ -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)

View File

@ -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:

View File

@ -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,

View File

@ -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]