C-1: nest_by_sums включается из osv.py, если схема шапки не называет группы после счёта; строка с ИНН не вкладывается и не становится родителем; прямые дети родителя должны быть на одном отступе. M-1: сдвиги складываются. M-2: код без разделителя сегментов не вкладывается. M-3: строка закрытия — только с ненулевыми оборотами. M-4: общий жадный перебор _greedy_ends для strategy_sums и вложенности, граница NEST_SCAN_LIMIT под тестом. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
579 lines
33 KiB
Python
579 lines
33 KiB
Python
"""Строки табличной части ОСВ и их классификация (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
|