Строки-показатели вне каскада иерархии (#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): метка вида суммы строки и привязка к строке данных.""" """Строки-показатели ОСВ (§1.3, AURORA-1613, AURORA-1661): метка вида суммы строки и привязка к строке данных."""
from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from src.contract.common import DataType from src.contract.common import DataType
from src.parser.deadline import Deadline
from src.parser.detect import TableHeader from src.parser.detect import TableHeader
from src.parser.grid import Sheet 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 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) @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) label = column_label(sheet, row, header)
return bool(label) and first_match(rules.data_types.indicator_column, label) != "BU" 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 typing import Literal
from src.contract.result import OsvGroup from src.contract.result import OsvGroup
from src.parser.classify import row_data_type
from src.parser.grid import Cell, Sheet from src.parser.grid import Cell, Sheet
from src.parser.header import InnHint from src.parser.header import InnHint
from src.parser.hierarchy import BodyRow, Levels from src.parser.hierarchy import BodyRow, Levels
@ -104,7 +103,6 @@ def _build( # noqa: PLR0913, PLR0917 - всё нужное для вида ст
hint: InnHint | None, hint: InnHint | None,
rules: Rules, rules: Rules,
) -> _Tree: ) -> _Tree:
currency = frozenset(rules.data_types.currency_codes)
children: dict[int | None, list[int]] = {} children: dict[int | None, list[int]] = {}
for item in rows: for item in rows:
children.setdefault(levels.parent[item.row], []).append(item.row) children.setdefault(levels.parent[item.row], []).append(item.row)
@ -112,7 +110,7 @@ def _build( # noqa: PLR0913, PLR0917 - всё нужное для вида ст
for siblings in children.values(): for siblings in children.values():
tree.position.update((row, index) for index, row in enumerate(siblings)) tree.position.update((row, index) for index, row in enumerate(siblings))
for item in rows: 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": if hint is not None and hint.direction == "below":
# «ИНН не указан» (R-1 #32): с подсказкой «под» пустая строка-ребёнок наименования — строка ИНН без значения # «ИНН не указан» (R-1 #32): с подсказкой «под» пустая строка-ребёнок наименования — строка ИНН без значения
for item in rows: for item in rows:
@ -129,14 +127,13 @@ def _kind( # noqa: PLR0913, PLR0917 - вид строки зависит от
sheet: Sheet, sheet: Sheet,
hint: InnHint | None, hint: InnHint | None,
rules: Rules, rules: Rules,
currency: frozenset[str],
tree: _Tree, tree: _Tree,
) -> Kind: ) -> Kind:
if total_row is not None and item.row >= total_row: if total_row is not None and item.row >= total_row:
return "total" return "total"
if item.is_account: if item.is_account:
return "account" return "account"
if row_data_type(item.text, rules, currency) is not None: if item.indicator:
return "indicator" return "indicator"
found = row_inn(_label_cell(sheet, item), rules) found = row_inn(_label_cell(sheet, item), rules)
if found is None: 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.errors import InvalidFormatError
from src.parser.grid import Sheet, column_letter 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.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.inn_rows import InnGlue, glue_inn_rows
from src.parser.osv_warnings import ( from src.parser.osv_warnings import (
OSV_HEADER, OSV_HEADER,
@ -181,7 +181,7 @@ class OsvParser:
step = "строки таблицы" step = "строки таблицы"
body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline) body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline)
step = "иерархия строк" step = "иерархия строк"
levels = resolve_levels(body, total_row, deadline) levels = levels_with_indicators(body, total_row, deadline)
step = "строки ИНН" step = "строки ИНН"
glue = glue_inn_rows( glue = glue_inn_rows(
body, body,

View File

@ -1,11 +1,15 @@
"""Метки строк-показателей (§1.3, #58): колонка «Показатели» и текст строки.""" """Метки строк-показателей (§1.3, #58): колонка «Показатели» и текст строки."""
from decimal import Decimal
import pytest import pytest
from gridbuilder import L, M, at, sheet_from_rows 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.detect import TableHeader, find_table_header
from src.parser.grid import Sheet 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 from src.parser.rules import Rules, load_rules
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г." 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 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 + 1, header, rules)
assert not is_indicator_total(sheet, FIRST_ROW + 2, 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]