"""Строки табличной части ОСВ и их классификация (spec «Классификация строк», design D-4; `05-domain-rules.md` §1.2–§1.4). Два шага вокруг каскада иерархии (`hierarchy.resolve_levels`): 1. `collect_body_rows` — строки таблицы от первой строки данных до строки «Итого» включительно: текст строки, признак счёта, оформление и шесть сумм (по `ColumnMap`; свёрнутое сальдо раскладывается по знаку, ruling R-1). Строка «Итого» выбирается здесь, по тексту и положению, до уровней: каскаду она нужна, чтобы закрепить её на уровне 0. 2. `classify_rows` — тип группировки строки по позиции в `group_schema`, счёт ветки, контрагент и ИНН, `data_type`, нераспознанные колонки таблицы (`extra`). ИНН из строки ИНН и строки ИНН без пары приходят из `inn_rows.glue_inn_rows` (`inns`, `unpaired`): между шагами 1 и 2 стоит проход склейки строк ИНН (#35). Позиции строк и колонок берутся только из `TableHeader` и содержимого ячеек; единственная регулярка в коде — `ACCOUNT_RE`: формат кода счёта — структура, а не синоним (цифры только ASCII, название после запятой — в том числе многострочное, M-1). Буквенные сегменты субсчёта (R-16) оставляют известный остаточный ложноположительный случай: текст вида «12.ООО» (2–3 цифры, точка, до трёх прописных букв) — счёт. """ import re from collections.abc import Mapping, Sequence from collections.abc import Set as AbstractSet from dataclasses import dataclass, field from decimal import Decimal from typing import Literal from src.contract.common import DataType from src.contract.result import OsvGroup from src.parser.deadline import Deadline from src.parser.detect import ( GROUP_ORDER, ColumnMap, TableHeader, first_data_row, is_header_repeat, table_kind_row, ) from src.parser.grid import Sheet, column_letter from src.parser.hierarchy import ACCOUNT_SEGMENT_SEPARATOR, BodyRow, Levels from src.parser.indicators import is_indicator_total, read_label from src.parser.numbers import column_inn, split_inn_suffix, to_money from src.parser.rules import GroupType, Rules __all__ = [ "ACCOUNT_RE", "match_account", "DEADLINE_CHECK_EVERY_ROWS", "SUM_FIELDS", "ClassifiedRow", "RowGroupType", "classify_rows", "collect_body_rows", ] # Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или # 1–3 прописные буквы кириллицы/латиницы (буквенные субсчета 1С «76.АВ», «76.ВА», «76.ОТ», R-16; регистр учитывается: # «76.ав» — не счёт). После запятой — название («60.01, Расчеты с поставщиками»; пробел после запятой не обязателен, # R-18). Группа 1 — код как в файле (`account_code_raw`), группа 2 — название. # Флаги (M-1): re.ASCII — `\d` только 0–9 (арабско-индийские «٦٠» и полноширинные цифры — не код счёта; явные диапазоны # `[А-ЯЁA-Z]` флаг не трогает), re.DOTALL — название после запятой может занимать несколько строк ячейки # («60.01, Расчеты\nс поставщиками» — счёт, а не контрагент, который сдвинул бы group_type детей) ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL) # Субсчёт с тремя цифрами в сегменте («60.111», «60.243»): счёт, только если код начинается со счёта шапки (§1.3, # AURORA-1714); без счёта шапки действует `ACCOUNT_RE` _WIDE_ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.\d{1,3}){1,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL) _SEGMENT_SEPARATOR = ACCOUNT_SEGMENT_SEPARATOR def match_account(text: str, header_account: str | None = None) -> re.Match[str] | None: """Код счёта в тексте строки: `ACCOUNT_RE`, а при счёте шапки — и его субсчета с 1–3 цифрами в сегменте.""" found = ACCOUNT_RE.match(text) if found is not None or not header_account or not text.startswith(f"{header_account}{_SEGMENT_SEPARATOR}"): return found return _WIDE_ACCOUNT_RE.match(text) # Сдвиг на одну колонку текста в «индентах» строки: больше любого отступа ячейки LABEL_COLUMN_INDENT_STEP = 1000 # Строка итога без подписи возможна, только если над ней есть хотя бы одна строка данных MIN_ROWS_WITH_TOTAL = 2 # Кооперативный дедлайн (design D-8): проверка каждые N строк листа DEADLINE_CHECK_EVERY_ROWS = 500 # Шесть сумм строки в порядке полей `ColumnMap` и `OsvRow` контракта SUM_FIELDS = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit") # Позиция группировки в `group_schema`: индекс 0 — ACCOUNT, первая не-счётная группировка — индекс 1 _ACCOUNT_POSITION = 0 RowGroupType = GroupType | Literal["TOTAL"] @dataclass(frozen=True) class ClassifiedRow: """Строка `rows[]` контракта до сборки `OsvRow`: исходная строка, место в дереве и классификация.""" body: BodyRow level: int parent: int | None group_type: RowGroupType group_name: str | None account_code_raw: str | None # код ближайшего счёта ветки, как в файле counterparty_name: str | None counterparty_inn: str | None data_type: DataType | None extra: dict[str, str | None] | None indicator: bool = False # строка-показатель (§1.3): вне проверок сумм, `data_type` — вид по метке # COUNTERPARTY получен по плану счетов (вид субконто в шапке не назван, #59, R-6), а не по схеме группировок counterparty_by_account: bool = False # Строка группировки-периода («Период» схемы шапки, `period_groups`): помесячный разрез родителя (AURORA-1823) is_period: bool = False # --- Строки таблицы --- def _in_table(sheet: Sheet, row: int, header: TableHeader) -> bool: """В строке есть значение в пределах таблицы; строка лишь с ячейками правее правого края — не строка таблицы.""" return any(col <= header.right_edge for col, _ in sheet.row_cells(row)) def _table_rows(sheet: Sheet, header: TableHeader, rules: Rules, deadline: Deadline) -> list[int]: """Непустые строки после блока заголовка, кроме повторов заголовка и строк вне таблицы (Review Focus 4, 5). Строка из одних пробелов пустая: `Sheet` не хранит такие ячейки в строках, а повтор заголовка — не данные. """ found: list[int] = [] kind_row = table_kind_row(sheet, header, rules) # вид субконто строкой в таблице — не данные for index, row in enumerate(range(first_data_row(sheet, header), sheet.n_rows + 1), start=1): if kind_row is not None and row == kind_row[0]: continue if index % DEADLINE_CHECK_EVERY_ROWS == 0: deadline.check("строки таблицы") if sheet.is_row_empty(row) or not _in_table(sheet, row, header): continue if is_header_repeat(sheet, row, header, rules): continue found.append(row) return found def _text_cell_style(sheet: Sheet, row: int, col: int) -> tuple[bool, int]: cell = sheet.cell(*sheet.merged_origin(row, col)) return cell.bold, cell.indent def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: Rules) -> int | None: """Строка «Итого»: первая строка таблицы, чей текст матчит `total_row`, на корневом уровне оформления. Таблица кончается на первой «Итого» (AURORA-1664): всё ниже — вне документа, даже если там снова строки «Итого …» (аналитик дописывает под ОСВ подпись и свои блоки ГК/ГСК с итогами). Сначала ищется первая строка корневого уровня, чей текст — ровно «Итого» (`total_row_exact`), и только если такой нет — первая по `total_row` («Итого …»; I-1: иначе контрагент «Итого-Сервис» на плоском листе обрезал бы таблицу). Суммы не требуются (R-13: пустая ОСВ — «Итого» без сумм). Выбор — до уровней (их каскад считает с уже известной строкой «Итого»), поэтому «корневой уровень» проверяется по оформлению: outline level и отступ строки не больше, чем у любой другой строки таблицы. Так контрагент «Итого-Сервис» под счётом с outline/отступом — не TOTAL (T3.3), а настоящее «Итого» ниже него — TOTAL. Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — обычная «Итого» (в плоской форме она может стоять после «Итого развернутое»); без обычной «Итого» первая вторая строка служит единственной итоговой. Остаточный риск: если точного «Итого» нет вовсе, а на листе нет outline и отступов, контрагент «Итого-…» на корневом уровне становится TOTAL; это ловит проверка «TOTAL = корень» (§1.4). """ if not rows: return None outline_floor = min(sheet.outline(row) for row in rows) indent_floor = min(_text_cell_style(sheet, row, header.text_col)[1] for row in rows) roots = [ row for row in rows if sheet.outline(row) == outline_floor and _text_cell_style(sheet, row, header.text_col)[1] == indent_floor ] for patterns in (rules.headers.total_row_exact, rules.headers.total_row): only_secondary: int | None = None for row in roots: text = sheet.text_at(row, header.text_col) if not any(pattern.search(text) for pattern in patterns): continue if not _is_secondary_total(text, rules): return row only_secondary = only_secondary or row if only_secondary is not None: return only_secondary return None def _numbers_below(sheet: Sheet, header: TableHeader, last_row: int) -> list[int]: """Строки ниже `last_row` с числовыми ячейками в колонках таблицы (от колонки текста до правого края сумм): хвост вне документа. Числа правее таблицы (контрольные суммы) хвостом не считаются.""" return sorted( { row for (row, col), cell in sheet.cells.items() if row > last_row and cell.kind == "number" and header.text_col <= col <= header.right_edge } ) def _tail_warning(rows: Sequence[int]) -> str: where = f"строка {rows[0]}" if len(rows) == 1 else f"строки {rows[0]}–{rows[-1]}" return f"{where}: ниже таблицы (после «Итого») есть числа — строки ниже «Итого» вне документа и не разобраны" def _is_unlabelled_total(sheet: Sheet, header: TableHeader, rows: Sequence[int]) -> bool: """Последняя строка таблицы — одни суммы, левее колонок сумм пусто (1С без подписи «Итого»); над ней есть данные.""" if len(rows) < MIN_ROWS_WITH_TOTAL: return False first_sum = _first_sum_col(header) return all(col >= first_sum for col, _ in sheet.row_cells(rows[-1])) def _is_secondary_total(text: str, rules: Rules) -> bool: return any(pattern.search(text) for pattern in rules.headers.total_row_secondary) def _trailing_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], total: int, rules: Rules) -> int: """Номер последней из вторых итоговых строк, идущих в таблице сразу за основной «Итого» (она сама, если их нет): «Итого развернутое» и итоговые строки-показатели («Итого» с меткой «НУ», AURORA-1613).""" last = total for row in rows[rows.index(total) + 1 :]: secondary = _is_secondary_total(sheet.text_at(row, header.text_col), rules) if not (secondary or is_indicator_total(sheet, row, header, rules)): break last = row return last def _leading_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], total: int, rules: Rules) -> int: """Номер самой верхней из вторых итоговых строк сразу перед основной «Итого» (она сама, если их нет). Плоская форма 1С (#59): «Итого развернутое» стоит перед «Итого» — вместе они итоговый блок. Строки-показатели («НУ» под «Итого развернутое») шагу не мешают, но в блок входят, только если выше них найдена вторая итоговая строка (R-21): «НУ» под последней строкой данных в блок не втягивается. """ first = total for row in reversed(rows[: rows.index(total)]): if _is_secondary_total(sheet.text_at(row, header.text_col), rules): first = row elif not is_indicator_total(sheet, row, header, rules): break return first def _split_collapsed(sums: dict[str, Decimal | None], columns: ColumnMap) -> None: """Свёрнутое сальдо (одна колонка, значение в `*_debit`): отрицательное — в `*_credit` по модулю (R-1).""" for group in columns.collapsed: debit, credit = f"{group}_debit", f"{group}_credit" value = sums[debit] if value is not None and value < 0: sums[debit], sums[credit] = None, -value def _sums(sheet: Sheet, row: int, columns: ColumnMap, warnings: list[str]) -> tuple[Decimal | None, ...]: """Шесть сумм в порядке `SUM_FIELDS`; нечисловое значение — `None` и предупреждение с номером строки.""" sums: dict[str, Decimal | None] = dict.fromkeys(SUM_FIELDS) for name, col in columns.indexes().items(): parsed = to_money(sheet.cell(row, col)) if parsed.warning is not None: warnings.append(f"строка {row}: {parsed.warning}") sums[name] = parsed.value _split_collapsed(sums, columns) return tuple(sums.values()) def _first_sum_col(header: TableHeader) -> int: return min(header.columns.indexes().values()) def _label_cols(sheet: Sheet, header: TableHeader, rows: Sequence[int]) -> tuple[int, ...]: """Колонки текста строки: колонка текста таблицы и колонки правее неё (левее сумм, кроме ИНН), в которых текст стоит вместо пустой ячейки колонки текста. Так выглядит «Счет: Код / Наименование» с контрагентами правее кода: уровень строки задаёт колонка, в которой стоит её текст. У обычных таблиц текст всегда в колонке текста — вторых колонок нет. """ first_sum = _first_sum_col(header) found: set[int] = set() for row in rows: if sheet.text_at(row, header.text_col): continue found.update( col for col, _ in sheet.row_cells(row) if header.text_col < col < first_sum and col not in (header.inn_col, header.indicator_col) and col <= header.right_edge ) return (header.text_col, *sorted(found)) def _label_of(sheet: Sheet, row: int, label_cols: Sequence[int]) -> tuple[int, str]: """Колонка и текст строки: первая непустая колонка текста слева направо (без текста — колонка текста таблицы).""" for col in label_cols: text = sheet.text_at(row, col) if text: return col, text return label_cols[0], "" def _body_row( # noqa: PLR0913, PLR0917 - всё, что нужно строке, приходит явно sheet: Sheet, row: int, header: TableHeader, label_cols: Sequence[int], warnings: list[str], rules: Rules ) -> BodyRow: label_col, text = _label_of(sheet, row, label_cols) label = read_label(sheet, row, text, header, rules) if label.text is not None: text = label.text # у строки с меткой колонки «Показатели» текст — метка, не название if label.warning is not None: warnings.append(label.warning) account = ACCOUNT_RE.match(text) bold, indent = _text_cell_style(sheet, row, label_col) # Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже # любого отступа ячейки indent += label_cols.index(label_col) * LABEL_COLUMN_INDENT_STEP inn_in_column = header.inn_col is not None and column_inn(sheet.cell(row, header.inn_col), rules) is not None return BodyRow( row=row, text=text, is_account=account is not None, account_code=None if account is None else account.group(1), account_rank=None if account is None else account.group(1).count(_SEGMENT_SEPARATOR) + 1, bold=bold, indent=indent, outline=sheet.outline(row), sums=_sums(sheet, row, header.columns, warnings), label_col=label_col, indicator=label.indicator, label_data_type=label.data_type, has_inn=inn_in_column or split_inn_suffix(text, rules)[1] is not None, ) def _collapsed_warning(columns: ColumnMap) -> str: groups = ", ".join(group for group in GROUP_ORDER if group in columns.collapsed) return ( f"свёрнутое сальдо: {groups} — одна колонка без «Дебет/Кредит»: положительное значение отнесено в дебет, " "отрицательное (по модулю) — в кредит" ) def collect_body_rows( sheet: Sheet, header: TableHeader, rules: Rules, deadline: Deadline ) -> tuple[list[BodyRow], int | None, list[str]]: """Строки данных листа (включая «Итого»), номер строки «Итого» (`None` — не найдена) и предупреждения. Строки после «Итого» — подвал (подписи, числа правее таблицы): ни в данные, ни в `extra` они не попадают (T4.5); исключение — вторые итоговые строки («Итого развернутое»): они остаются в таблице как TOTAL и стоят как сразу за «Итого», так и перед ней (плоская форма 1С); возвращаемый номер — первая строка итогового блока. """ warnings: list[str] = [] if header.columns.collapsed: warnings.append(_collapsed_warning(header.columns)) rows = _table_rows(sheet, header, rules, deadline) total_row = _find_total(sheet, header, rows, rules) if total_row is None and _is_unlabelled_total(sheet, header, rows): total_row = rows[-1] warnings.append( f"строка {total_row}: строка сумм без подписи в конце таблицы принята за «Итого» (подписи в файле нет)" ) if total_row is not None: last = rows.index(_trailing_totals(sheet, header, rows, total_row, rules)) tail = _numbers_below(sheet, header, rows[last]) if tail: warnings.append(_tail_warning(tail)) rows = rows[: last + 1] total_row = _leading_totals(sheet, header, rows, total_row, rules) label_cols = _label_cols(sheet, header, rows) body: list[BodyRow] = [] for index, row in enumerate(rows, start=1): if index % DEADLINE_CHECK_EVERY_ROWS == 0: deadline.check("суммы строк") body.append(_body_row(sheet, row, header, label_cols, warnings, rules)) return body, total_row, warnings # --- Классификация --- def _extra_keys(sheet: Sheet, header: TableHeader, rows: Sequence[BodyRow]) -> dict[int, str]: """Колонки `extra` со значениями в строках таблицы и их ключи без повторов. `header.extra_cols` собран по всем строкам листа, поэтому колонки, заполненные только в подвале, отбрасываются. Одинаковые заголовки двух колонок различаются буквой колонки в скобках: «Кол-во (B)», «Кол-во (C)» (Review Focus 1); заголовок без повторов — как в файле. """ label_cols = {item.label_col for item in rows} used = { col: title for col, title in header.extra_cols.items() if col not in label_cols and any(sheet.text_at(item.row, col) for item in rows) } titles = list(used.values()) return {col: f"{title} ({column_letter(col)})" if titles.count(title) > 1 else title for col, title in used.items()} def _extra(sheet: Sheet, row: int, keys: dict[int, str]) -> dict[str, str | None] | None: values = {key: sheet.text_at(row, col) or None for col, key in keys.items()} return values if any(value is not None for value in values.values()) else None @dataclass(frozen=True) class _Branch: """Контекст строки для её детей: счёт ветки и число не-счётных группировок после последнего счёта.""" account_code_raw: str | None depth: int _ROOT_BRANCH = _Branch(None, 0) @dataclass(frozen=True) class _Kind: """Классификация одной строки (без места в дереве и `extra`).""" group_type: RowGroupType group_name: str | None account_code_raw: str | None data_type: DataType | None counterparty_name: str | None = None counterparty_inn: str | None = None indicator: bool = False counterparty_by_account: bool = False is_period: bool = False @dataclass(frozen=True) class _Context: schema: Sequence[OsvGroup] header: TableHeader sheet: Sheet doc_data_type: DataType | None rules: Rules currency_codes: frozenset[str] inns: Mapping[int, str] unpaired: AbstractSet[int] header_account: str | None = None names: Mapping[int, str] = field(default_factory=dict) # названия контрагентов, склеенные с кодом (`code_blocks`) def _schema_group(schema: Sequence[OsvGroup], position: int) -> tuple[GroupType, str | None]: if position >= len(schema): return "OTHER", None group = schema[position] return group.type, group.name def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple[_Kind, _Branch]: """Тип строки (кроме TOTAL) по признаку счёта, словарю строк-показателей и позиции в схеме; контекст для детей.""" code = inherited.account_code_raw if item.indicator: # Строка-показатель (§1.3): не уровень группировки, а вид сумм родителя; позицию в схеме не занимает return _Kind("OTHER", None, code, item.label_data_type, indicator=True), inherited # ASSUMPTION(OQ-45): `data_type` шапки (в том числе НУ) — как есть; метка строки («БУ») важнее шапки own = item.label_data_type or context.doc_data_type account = match_account(item.text, context.header_account) if item.is_account else None if account is not None: code = account.group(1) _, name = _schema_group(context.schema, _ACCOUNT_POSITION) return _Kind("ACCOUNT", name, code, own), _Branch(code, 0) if item.row in context.unpaired: # Строка ИНН без пары (#35): OTHER, позицию в схеме не занимает — её дети классифицируются на её месте return _Kind("OTHER", None, code, own), inherited position = inherited.depth + 1 group_type, name = _schema_group(context.schema, position) branch = _Branch(code, position) # Схема из одного ACCOUNT, первый уровень под расчётным счётом без названия вида — контрагент по плану счетов # (решение владельца №3, R-6); строки, которые словарь групп явно знает как не-контрагентов (валюта), — нет (R-18) known = context.rules.groups.known(item.text) by_account = ( group_type == "OTHER" and name is None and position == 1 and position >= len(context.schema) and (known is None or known == "COUNTERPARTY") and context.rules.groups.is_counterparty_account(code) ) if by_account: group_type = "COUNTERPARTY" if group_type == "OTHER" and name is None and item.role == "contract": group_type = "CONTRACT" # договор контрагента в блоке субсчёта с кодами контрагентов (§1.3, `code_blocks`) if group_type != "COUNTERPARTY": is_period = group_type == "OTHER" and context.rules.groups.is_period_group(name) return _Kind(group_type, name, code, own, is_period=is_period), branch inn_col = context.header.inn_col column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules) # ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100) counterparty, in_name = split_inn_suffix(context.names.get(item.row, item.text), context.rules) inn = column or context.inns.get(item.row) or in_name kind = _Kind( group_type, name, code, own, counterparty_name=counterparty, counterparty_inn=inn, counterparty_by_account=by_account, ) return kind, branch def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно rows: Sequence[BodyRow], levels: Levels, schema: Sequence[OsvGroup], header: TableHeader, sheet: Sheet, doc_data_type: DataType | None, total_row: int | None, rules: Rules, *, inns: Mapping[int, str] | None = None, unpaired: AbstractSet[int] = frozenset(), header_account: str | None = None, names: Mapping[int, str] | None = None, ) -> list[ClassifiedRow]: """Классификация строк по дереву `levels` и схеме группировок. - «Итого» (`total_row`) и вторые итоговые строки («Итого развернутое»; стоят после неё или перед ней) — TOTAL, уровень 0 без родителя; в `rows` остаются (# ASSUMPTION(OQ-01): хранить ли «Итого», решает ядро); - счёт (`ACCOUNT_RE`) — ACCOUNT, `account_code_raw` — код как в файле; дети наследуют код ветки; - строка-показатель (`BodyRow.indicator`: метка колонки «Показатели» не «БУ», «Вал.», «Кол.», код валюты, §1.3) — OTHER, `indicator=True`, `data_type` метки (НУ/CURRENCY/QUANTITY/`None`), `account_code_raw` ветки родителя, без контрагента: это не уровень группировки, а вид сумм родителя; позицию в схеме не занимает; итоговая строка-показатель — TOTAL с тем же признаком; - вид данных строки — метка (`label_data_type`), иначе `doc_data_type` (умолчание БУ подставляет `osv.py`); - прочая строка — `schema[позиция]`, позиция = число не-счётных предков после последнего счёта + 1; за схемой — OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН); - строка ИНН без пары (`unpaired`) — OTHER, позицию в схеме не занимает; ИНН склеенных строк — `inns`; - корневая ветка наследует счёт шапки `header_account` (R-4, #59): строки до первой строки-счёта и плоский список получают его код. """ context = _Context( schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes), inns or {}, unpaired, header_account, names or {}, ) extra_keys = _extra_keys(sheet, header, rows) root_branch = _Branch(header_account, 0) branches: dict[int, _Branch] = {} result: list[ClassifiedRow] = [] for item in rows: if total_row is not None and item.row >= total_row: # Всё от основной «Итого» и ниже — итоговые строки (подвал `collect_body_rows` уже отрезал) # ASSUMPTION(OQ-01): строка «Итого» остаётся в rows; хранить ли её, решает ядро kind = _Kind("TOTAL", None, None, item.label_data_type or doc_data_type, indicator=item.indicator) branch = _ROOT_BRANCH level, parent = 0, None else: parent = levels.parent[item.row] inherited = root_branch if parent is None else branches.get(parent, root_branch) kind, branch = _classify_row(item, inherited, context) level = levels.by_row[item.row] branches[item.row] = branch result.append( ClassifiedRow( body=item, level=level, parent=parent, group_type=kind.group_type, group_name=kind.group_name, account_code_raw=kind.account_code_raw, counterparty_name=kind.counterparty_name, counterparty_inn=kind.counterparty_inn, data_type=kind.data_type, extra=_extra(sheet, item.row, extra_keys), indicator=kind.indicator, counterparty_by_account=kind.counterparty_by_account, is_period=kind.is_period, ) ) return result