feat(parser): детекторы листов ОСВ, заголовка таблицы и колонок по содержимому

detect.py: classify_sheet (ОСВ / форма ББ-ОФР / прочее по словарю), find_table_header
(строка с ≥ 2 группами сумм, «Дебет/Кредит» под группой в пределах merged-диапазона
или до следующей группы, заголовок в 1–4 строки, в т.ч. «Нач. сальдо Дт» в одной
ячейке; без «начала»; свёрнутое сальдо в *_debit + collapsed; свёрнутые «Обороты» и
половинчатая пара Дт/Кт — InvalidFormatError с названием группы), text_col, inn_col
(заголовок «ИНН» или ≥ 80 % значений из 10/12 цифр), right_edge, extra_cols,
group_label_rows, first_data_row, is_header_repeat (две группы или копия строки блока).

Pattern.search_normalized — ячейка нормализуется один раз (T3.6). osv_groups: известные
группировки OTHER перечислены явно, чтобы строка-метка «Основные средства» в
неслитом заголовке была частью блока. Хелпер тестов gridbuilder (Sheet из «картинки»).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 12:54:11 +04:00
parent fcd609c633
commit a833945669
11 changed files with 1399 additions and 5 deletions

View File

@ -25,6 +25,10 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`.
«Д-т»), `\w` вместо диапазона. Загрузчик отвергает такой паттерн с указанием файла и ключа.
- Порядок записей в списках `mapping`, `rules` (периоды), `header`, `row` — приоритет: побеждает первое совпадение,
от специфичных правил к общим.
- В `osv_groups.yaml` известные группировки `OTHER` («Основные средства», «Номенклатура», «Статьи затрат», …)
перечислены в `mapping` явно, хотя `default` и так даёт `OTHER`: детектор заголовка таблицы (`src/parser/detect.py`)
считает строку под «Дебет/Кредит» частью блока заголовка, только если её текст совпал с записью `mapping`, с `ИНН`
или с «субконто». Новая группировка в отдельной строке заголовка = новая строка в `mapping`.
- Ошибка в словаре (нет обязательного ключа, неизвестный ключ, неверный регэксп или тип) — `RuntimeError` при
загрузке с именем файла и ключом.

View File

@ -5,6 +5,13 @@ mapping:
- ['^подразделени', DEPARTMENT]
- ['^контрагент', COUNTERPARTY]
- ['^договор', CONTRACT]
# Известные группировки OTHER перечислены явно: по ним детектор узнаёт строку-метку в блоке заголовка таблицы
# (строка «Основные средства» под «Счет / Подразделение» — часть заголовка, а не первая строка данных)
- ['^основн\w*\s+средств', OTHER]
- ['^нематериальн\w*\s+актив', OTHER]
- ['^номенклатур', OTHER]
- ['^стать(и|я)\b', OTHER]
- ['^валют', OTHER]
# Всё прочее («Валюта», «Номенклатура», «Статьи затрат», «Основные средства», …)
# Всё прочее (не перечисленное выше); такая группировка в отдельной строке блока заголовка не будет узнана как метка
default: OTHER

452
api/src/parser/detect.py Normal file
View File

@ -0,0 +1,452 @@
"""Детекторы структуры листа по содержимому (design D-2; `05-domain-rules.md` §1.0 п. 1–4, §1.2).
Чистые функции над `Sheet`: вид листа (ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы, шесть колонок сумм,
колонки текста строки и ИНН, дополнительные колонки, повтор заголовка внутри данных. Результат — индексы колонок и
номера строк, вычисленные из текста ячеек; ни одна позиция не задана заранее. Буквы колонок появляются только в
диагностике (`ColumnMap.as_diagnostics` через `grid.column_letter`). Синонимы — в `rules/*.yaml`.
"""
import re
from collections.abc import Iterable, Iterator
from dataclasses import dataclass, fields
from decimal import Decimal
from enum import StrEnum
from functools import lru_cache
from typing import Literal
from src.parser.errors import InvalidFormatError
from src.parser.grid import Cell, Sheet, column_letter
from src.parser.rules import Pattern, Rules, SumGroup, normalize_text
__all__ = [
"ColumnMap",
"SheetKind",
"TableHeader",
"classify_sheet",
"find_table_header",
"first_data_row",
"is_header_repeat",
]
CollapsedGroup = Literal["opening", "closing"]
Side = Literal["debit", "credit"]
# Признак ОСВ / формы ищется в начале листа: шапка ведомости всегда над таблицей
SHEET_MARKER_ROWS = 30
# Блок заголовка таблицы — от 1 до 4 строк: строка групп и до трёх строк под ней (подзаголовки, метки группировок)
HEADER_MAX_ROWS = 4
# Сколько строк данных смотреть, чтобы найти колонку текста строки и колонку ИНН по значениям
TEXT_COL_SAMPLE_ROWS = 20
INN_SAMPLE_ROWS = 200
# Колонка без заголовка «ИНН» считается колонкой ИНН, если не меньше 80 % её непустых значений — 10 или 12 цифр
INN_SHARE_PERCENT = 80
PERCENT = 100
INN_LENGTHS = frozenset({10, 12})
# Нужно не меньше двух групп сумм в одной строке, чтобы считать её заголовком таблицы (spec: «меньше двух» — ошибка)
MIN_GROUPS = 2
# Кэш нормализованного текста ячеек: каждая ячейка нормализуется один раз на все паттерны
_NORMALIZE_CACHE_SIZE = 16_384
GROUP_ORDER: tuple[SumGroup, ...] = ("opening", "turnover", "closing")
# Названия групп в сообщениях об ошибках (spec: INVALID_FORMAT «с названием ненайденной группы»)
GROUP_TITLES: dict[SumGroup, str] = {
"opening": "сальдо на начало",
"turnover": "обороты",
"closing": "сальдо на конец",
}
SIDE_TITLES: dict[Side, str] = {"debit": "Дебет", "credit": "Кредит"}
class SheetKind(StrEnum):
OSV = "osv"
STATEMENT = "statement"
OTHER = "other"
@dataclass(frozen=True)
class ColumnMap:
"""Шесть колонок сумм (1-based индексы; `None` — группы нет).
Свёрнутое сальдо (одна колонка в группе без «Дебет/Кредит») кладётся в `*_debit`, `*_credit = None`, группа
попадает в `collapsed`; раскладку значения по знаку делает классификатор строк (ruling R-1).
"""
opening_debit: int | None
opening_credit: int | None
turnover_debit: int | None
turnover_credit: int | None
closing_debit: int | None
closing_credit: int | None
collapsed: frozenset[CollapsedGroup] = frozenset()
def indexes(self) -> dict[str, int]:
"""Заданные колонки сумм по именам полей (`opening_debit`, …), в порядке полей."""
found: dict[str, int] = {}
for field in fields(self):
value = getattr(self, field.name)
if isinstance(value, int):
found[field.name] = value
return found
def as_diagnostics(self) -> dict[str, str]:
"""Диагностика для `columns` контракта: `{"opening_debit": "E", …}` — только найденные колонки."""
return {name: column_letter(index) for name, index in self.indexes().items()}
@dataclass(frozen=True)
class TableHeader:
"""Блок заголовка таблицы ОСВ и колонки, определённые по нему и по первым строкам данных."""
first_row: int # строка групп сумм
last_row: int # последняя строка блока (включительно): подзаголовки, их merged-продолжение, метки группировок
columns: ColumnMap
text_col: int
inn_col: int | None
right_edge: int # последняя колонка таблицы (правый край последней колонки сумм); правее — не таблица
extra_cols: dict[int, str] # колонка → заголовок в блоке (или буква колонки, если заголовка нет)
group_label_rows: list[int] # строки блока, где в text_col начинается текст группировки («Счет», «Контрагенты»)
# --- Текст ячеек ---
@lru_cache(maxsize=_NORMALIZE_CACHE_SIZE)
def _normalized(text: str) -> str:
return normalize_text(text)
def _search(patterns: Iterable[Pattern], normalized: str) -> re.Match[str] | None:
for pattern in patterns:
found = pattern.search_normalized(normalized)
if found is not None:
return found
return None
def _texts(sheet: Sheet, row: int) -> Iterator[tuple[int, str]]:
"""`(col, нормализованный текст)` непустых ячеек строки (merged — только origin)."""
for col, cell in sheet.row_cells(row):
yield col, _normalized(cell.text)
# --- Вид листа ---
def classify_sheet(sheet: Sheet, rules: Rules) -> SheetKind:
"""ОСВ — если в первых строках есть признак ведомости; иначе форма ББ/ОФР по её признакам; иначе прочее."""
head = [text for row in range(1, min(sheet.n_rows, SHEET_MARKER_ROWS) + 1) for _, text in _texts(sheet, row)]
if any(_search(rules.headers.sheet_markers, text) for text in head):
return SheetKind.OSV
if any(_search(rules.headers.statement_markers, text) for text in head):
return SheetKind.STATEMENT
return SheetKind.OTHER
# --- Заголовок таблицы ---
@dataclass(frozen=True)
class _GroupCell:
"""Ячейка строки групп, совпавшая с группой сумм; `side` — «Дт/Кт» в той же ячейке (заголовок в одну строку)."""
group: SumGroup
col: int
side: Side | None
@dataclass(frozen=True)
class _SumCell:
"""Найденная колонка суммы: индекс и строки/правый край ячейки заголовка (с учётом merged)."""
col: int
top: int
bottom: int
right: int
def _side(normalized: str, rules: Rules) -> Side | None:
if _search(rules.headers.debit, normalized):
return "debit"
if _search(rules.headers.credit, normalized):
return "credit"
return None
def _group_cells(sheet: Sheet, row: int, rules: Rules) -> list[_GroupCell]:
"""Ячейки строки, где найдена группа сумм; у каждой ячейки — не больше одной группы (порядок групп — приоритет)."""
found: list[_GroupCell] = []
for col, text in _texts(sheet, row):
for group in GROUP_ORDER:
match = _search(rules.headers.groups[group], text)
if match is not None:
found.append(_GroupCell(group, col, _side(text[match.end() :].strip(), rules)))
break
return found
def _sum_cell(sheet: Sheet, row: int, col: int) -> _SumCell:
span = sheet.merged_span(row, col)
if span is None:
return _SumCell(col, row, row, col)
return _SumCell(col, span.top, span.bottom, span.right)
def _missing_groups_message(sheet: Sheet, found: set[SumGroup]) -> str:
missing = ", ".join(f"«{GROUP_TITLES[group]}»" for group in GROUP_ORDER if group not in found)
return f"лист «{sheet.name}»: не найден заголовок таблицы ОСВ — нет групп сумм {missing}"
def _find_group_row(sheet: Sheet, rules: Rules) -> tuple[int, list[_GroupCell]]:
"""Первая сверху строка, где встретились хотя бы две группы сумм; иначе `InvalidFormatError` с недостающими."""
best: set[SumGroup] = set()
for row in range(1, sheet.n_rows + 1):
cells = _group_cells(sheet, row, rules)
groups = {cell.group for cell in cells}
if len(groups) >= MIN_GROUPS:
return row, cells
if len(groups) > len(best):
best = groups
raise InvalidFormatError(_missing_groups_message(sheet, best))
@dataclass(frozen=True)
class _GroupColumns:
debit: _SumCell | None
credit: _SumCell | None
collapsed: _SumCell | None
def _columns_of_group(
sheet: Sheet, group_row: int, group_cells: list[_GroupCell], group: SumGroup, rules: Rules
) -> _GroupColumns:
own = [cell for cell in group_cells if cell.group == group]
sided = [cell for cell in own if cell.side is not None]
if sided:
# Заголовок в одну строку: «Нач. сальдо Дт» | «Нач. сальдо Кт»
debit = next((_sum_cell(sheet, group_row, c.col) for c in sided if c.side == "debit"), None)
credit = next((_sum_cell(sheet, group_row, c.col) for c in sided if c.side == "credit"), None)
return _check_sides(sheet, group, debit, credit)
title = own[0]
title_span = sheet.merged_span(group_row, title.col)
if title_span is not None and title_span.right > title.col:
right = title_span.right
else:
following = [cell.col for cell in group_cells if cell.col > title.col]
right = min(following) - 1 if following else sheet.n_cols
first_sub_row = group_row + 1 if title_span is None else title_span.bottom + 1
debit = credit = None
for row in range(first_sub_row, group_row + HEADER_MAX_ROWS):
for col, text in _texts(sheet, row):
if not title.col <= col <= right:
continue
side = _side(text, rules)
if side == "debit" and debit is None:
debit = _sum_cell(sheet, row, col)
elif side == "credit" and credit is None:
credit = _sum_cell(sheet, row, col)
if debit is None and credit is None:
if group == "turnover":
raise InvalidFormatError(
f"лист «{sheet.name}»: группа «{GROUP_TITLES[group]}» без колонок «Дебет»/«Кредит» — "
"свёрнутыми бывают только сальдо"
)
return _GroupColumns(None, None, _sum_cell(sheet, group_row, title.col))
return _check_sides(sheet, group, debit, credit)
def _check_sides(sheet: Sheet, group: SumGroup, debit: _SumCell | None, credit: _SumCell | None) -> _GroupColumns:
if debit is None or credit is None:
present: Side = "credit" if debit is None else "debit"
absent: Side = "debit" if debit is None else "credit"
raise InvalidFormatError(
f"лист «{sheet.name}»: группа «{GROUP_TITLES[group]}»: найден «{SIDE_TITLES[present]}», "
f"нет «{SIDE_TITLES[absent]}»"
)
return _GroupColumns(debit, credit, None)
def _is_label_row(sheet: Sheet, row: int, sums: range, rules: Rules) -> bool:
"""Строка-метка блока заголовка: левее сумм — текст группировки, «ИНН» или «Субконто», в колонках сумм — пусто.
Пустые колонки сумм отличают метку «Контрагенты» от строки данных с контрагентом, чьё имя так начинается.
"""
label_patterns = [
*(pattern for pattern, _ in rules.groups.mapping),
*rules.headers.service_columns["inn"],
*rules.headers.header_markers["subkonto"],
]
texts = list(_texts(sheet, row))
if any(col in sums for col, _ in texts):
return False
return any(col < sums.start and _search(label_patterns, text) for col, text in texts)
def _block_last_row(sheet: Sheet, group_row: int, sum_cells: list[_SumCell], rules: Rules) -> int:
"""Последняя строка блока: подзаголовки (с merged-продолжением) и подряд идущие строки-метки под строкой групп."""
covered = {row for cell in sum_cells for row in range(cell.top, cell.bottom + 1)}
sums = range(min(cell.col for cell in sum_cells), max(cell.right for cell in sum_cells) + 1)
last = group_row
for row in range(group_row + 1, group_row + HEADER_MAX_ROWS):
if row in covered or _is_label_row(sheet, row, sums, rules):
last = row
elif not sheet.is_row_empty(row):
break # первая строка данных
return last
def _data_rows(sheet: Sheet, after: int, limit: int | None = None) -> Iterator[int]:
"""Непустые строки после `after` (не больше `limit`)."""
taken = 0
for row in range(after + 1, sheet.n_rows + 1):
if limit is not None and taken >= limit:
return
if not sheet.is_row_empty(row):
taken += 1
yield row
def _text_col(sheet: Sheet, first_row: int, last_row: int, first_sum: int) -> int:
"""Первая непустая колонка левее сумм по первым строкам данных; без строк данных — по меткам блока."""
data = list(_data_rows(sheet, last_row, TEXT_COL_SAMPLE_ROWS))
rows = data if data else list(range(first_row, last_row + 1))
candidates = [col for row in rows for col, _ in sheet.row_cells(row) if col < first_sum]
if not candidates:
raise InvalidFormatError(f"лист «{sheet.name}»: не найдена колонка текста строк слева от колонок сумм")
return min(candidates)
def _inn_digits(cell: Cell) -> str:
"""Текст значения для проверки «10/12 цифр»: целое число из числовой ячейки — без дробной части и экспоненты."""
value = cell.value
if isinstance(value, Decimal) and value.is_finite() and value == value.to_integral_value():
return str(int(value))
return cell.text
def _is_inn(cell: Cell) -> bool:
digits = _inn_digits(cell)
return digits.isdigit() and len(digits) in INN_LENGTHS
def _inn_col(sheet: Sheet, block: range, text_col: int, first_sum: int, rules: Rules) -> int | None:
"""Колонка между текстом и суммами: по заголовку «ИНН» в блоке, иначе по доле значений из 10/12 цифр."""
inn_patterns = rules.headers.service_columns["inn"]
for row in block:
for col, text in _texts(sheet, row):
if text_col < col < first_sum and _search(inn_patterns, text):
return col
values: dict[int, list[Cell]] = {}
for row in _data_rows(sheet, block[-1], INN_SAMPLE_ROWS):
for col, cell in sheet.row_cells(row):
if text_col < col < first_sum:
values.setdefault(col, []).append(cell)
for col in sorted(values):
cells = values[col]
inn_count = sum(1 for cell in cells if _is_inn(cell))
if inn_count * PERCENT >= INN_SHARE_PERCENT * len(cells):
return col
return None
def _block_title(sheet: Sheet, first_row: int, last_row: int, col: int) -> str | None:
"""Заголовок колонки в блоке: текст самой нижней непустой ячейки блока ровно в этой колонке."""
for row in range(last_row, first_row - 1, -1):
text = sheet.cell(row, col).text
if text:
return text
return None
def _group_label_rows(sheet: Sheet, first_row: int, last_row: int, text_col: int) -> list[int]:
labels: list[int] = []
for row in range(first_row, last_row + 1):
origin_row, origin_col = sheet.merged_origin(row, text_col)
if origin_row == row and sheet.cell(origin_row, origin_col).text:
labels.append(row)
return labels
def find_table_header(sheet: Sheet, rules: Rules) -> TableHeader:
"""Блок заголовка таблицы и колонки; не найдено — `InvalidFormatError` с названием ненайденного."""
group_row, group_cells = _find_group_row(sheet, rules)
found = {cell.group for cell in group_cells}
by_group = {group: _columns_of_group(sheet, group_row, group_cells, group, rules) for group in found}
def index(group: SumGroup, side: Side) -> int | None:
columns = by_group.get(group)
if columns is None:
return None
if columns.collapsed is not None:
return columns.collapsed.col if side == "debit" else None
cell = columns.debit if side == "debit" else columns.credit
return None if cell is None else cell.col
collapsed: frozenset[CollapsedGroup] = frozenset(
group for group in ("opening", "closing") if group in by_group and by_group[group].collapsed is not None
)
columns = ColumnMap(
opening_debit=index("opening", "debit"),
opening_credit=index("opening", "credit"),
turnover_debit=index("turnover", "debit"),
turnover_credit=index("turnover", "credit"),
closing_debit=index("closing", "debit"),
closing_credit=index("closing", "credit"),
collapsed=collapsed,
)
sum_cells = [
cell
for group_columns in by_group.values()
for cell in (group_columns.debit, group_columns.credit, group_columns.collapsed)
if cell is not None
]
sum_cols = set(columns.indexes().values())
first_sum = min(sum_cols)
right_edge = max(cell.right for cell in sum_cells)
last_row = _block_last_row(sheet, group_row, sum_cells, rules)
text_col = _text_col(sheet, group_row, last_row, first_sum)
inn_col = _inn_col(sheet, range(group_row, last_row + 1), text_col, first_sum, rules)
partial = TableHeader(group_row, last_row, columns, text_col, inn_col, right_edge, {}, [])
occupied = {*sum_cols, text_col} | ({inn_col} if inn_col is not None else set())
extra_cols: dict[int, str] = {}
for row in _data_rows(sheet, last_row):
if is_header_repeat(sheet, row, partial, rules):
continue
for col, _ in sheet.row_cells(row):
if text_col < col <= right_edge and col not in occupied and col not in extra_cols:
extra_cols[col] = _block_title(sheet, group_row, last_row, col) or column_letter(col)
return TableHeader(
first_row=group_row,
last_row=last_row,
columns=columns,
text_col=text_col,
inn_col=inn_col,
right_edge=right_edge,
extra_cols=dict(sorted(extra_cols.items())),
group_label_rows=_group_label_rows(sheet, group_row, last_row, text_col),
)
def first_data_row(sheet: Sheet, header: TableHeader) -> int:
"""Первая непустая строка после блока заголовка; таблица без строк данных — `n_rows + 1`."""
return next(_data_rows(sheet, header.last_row), sheet.n_rows + 1)
def _signature(sheet: Sheet, row: int, right_edge: int) -> tuple[tuple[int, str], ...]:
return tuple((col, text) for col, text in _texts(sheet, row) if col <= right_edge)
def is_header_repeat(sheet: Sheet, row: int, header: TableHeader, rules: Rules) -> bool:
"""Строка данных — повтор заголовка (печать «по страницам»): две группы сумм или копия строки блока заголовка."""
if sheet.is_row_empty(row):
return False
if len({cell.group for cell in _group_cells(sheet, row, rules)}) >= MIN_GROUPS:
return True
signature = _signature(sheet, row, header.right_edge)
if not signature:
return False # только ячейки правее таблицы: не заголовок (и не данные таблицы)
return any(
signature == _signature(sheet, block_row, header.right_edge)
for block_row in range(header.first_row, header.last_row + 1)
)

View File

@ -93,6 +93,10 @@ class Pattern:
"""Первое совпадение в нормализованном тексте; позиции `Match` относятся к нормализованной строке."""
return self._regex.search(normalize_text(text))
def search_normalized(self, normalized: str) -> re.Match[str] | None:
"""Как `search`, но текст уже приведён `normalize_text`: ячейка нормализуется один раз на все паттерны."""
return self._regex.search(normalized)
def __repr__(self) -> str:
return f"Pattern({self.source!r})"

103
api/tests/fixtures/test_detect_real.py vendored Normal file
View File

@ -0,0 +1,103 @@
"""Детекторы на реальных ОСВ из ../fixtures: общие свойства на всех 66 файлах и две диагностики из спеки.
Фикстуры — регрессия, не оракул: проверяются свойства, верные для любой ОСВ (лист ОСВ найден, шесть сумм, текст левее
сумм, ИНН между текстом и суммами), и сценарий спеки «Плавающие колонки двух организаций». Буквы колонок сравниваются
только здесь, в тестах, через `ColumnMap.as_diagnostics()` / `column_letter`.
"""
import time
from pathlib import Path
from typing import cast
import pytest
from src.contract import FileType
from src.parser.detect import SheetKind, classify_sheet, find_table_header, first_data_row, is_header_repeat
from src.parser.grid import Sheet, column_letter
from src.parser.readers import read_book
from src.parser.rules import Rules, load_rules
DETECT_SECONDS_LIMIT = 5.0
RB_60 = "osv/rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx"
SCHASTL_62 = "osv/schastl/2024-Q3/Оборотносальдовая ведомость по счету 62 за 3 квартал 2024 г..xls"
SCHASTL_62_LABEL_ROWS = 3 # Счет / Подразделение / Контрагенты (+ ИНН)
SUM_COLUMNS = 6
HEADER_MAX_ROWS = 4
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def _sheets(path: Path) -> tuple[Sheet, ...]:
sheets, _ = read_book(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper()))
return sheets
@pytest.mark.fixtures
def test_every_real_osv_has_a_full_table_header(osv_file: Path, rules: Rules) -> None:
sheets = _sheets(osv_file)
started = time.perf_counter()
osv_sheets = [sheet for sheet in sheets if classify_sheet(sheet, rules) is SheetKind.OSV]
assert len(osv_sheets) == 1
sheet = osv_sheets[0]
header = find_table_header(sheet, rules)
data_start = first_data_row(sheet, header)
repeats = [row for row in range(data_start, sheet.n_rows + 1) if is_header_repeat(sheet, row, header, rules)]
assert time.perf_counter() - started < DETECT_SECONDS_LIMIT
sums = header.columns.indexes()
assert len(sums) == SUM_COLUMNS, header.columns.as_diagnostics()
assert len(set(sums.values())) == SUM_COLUMNS
assert header.columns.collapsed == frozenset()
columns = header.columns
for debit, credit in (
(columns.opening_debit, columns.opening_credit),
(columns.turnover_debit, columns.turnover_credit),
(columns.closing_debit, columns.closing_credit),
):
assert debit is not None and credit is not None and debit < credit
assert header.text_col < min(sums.values())
assert header.right_edge >= max(sums.values())
if header.inn_col is not None:
assert header.text_col < header.inn_col < min(sums.values())
assert all(header.text_col < col <= header.right_edge for col in header.extra_cols)
assert header.first_row <= header.last_row < header.first_row + HEADER_MAX_ROWS
assert header.group_label_rows and header.group_label_rows[0] == header.first_row
assert data_start > header.last_row
# Первая строка данных — не метка заголовка: у неё есть сумма, либо это «Итого» пустой ведомости
first_values = {col for col, _ in sheet.row_cells(data_start)}
assert first_values & set(sums.values()) or any(
pattern.search(sheet.text_at(data_start, header.text_col)) for pattern in rules.headers.total_row
), sheet.text_at(data_start, header.text_col)
assert repeats == []
@pytest.mark.fixtures
def test_rb_60_floating_columns(fixtures_dir: Path, rules: Rules) -> None:
sheet = _sheets(fixtures_dir / RB_60)[0]
header = find_table_header(sheet, rules)
diagnostics = header.columns.as_diagnostics()
assert [diagnostics[name] for name in header.columns.indexes()] == ["E", "H", "J", "L", "O", "Q"]
assert header.inn_col is None
assert column_letter(header.text_col) == "A"
@pytest.mark.fixtures
def test_schastl_62_floating_columns_inn_and_three_label_rows(fixtures_dir: Path, rules: Rules) -> None:
sheet = _sheets(fixtures_dir / SCHASTL_62)[0]
header = find_table_header(sheet, rules)
diagnostics = header.columns.as_diagnostics()
assert [diagnostics[name] for name in header.columns.indexes()] == ["G", "J", "K", "N", "Q", "S"]
assert header.inn_col is not None
assert column_letter(header.inn_col) == "E"
assert len(header.group_label_rows) == SCHASTL_62_LABEL_ROWS
# Мусор в T/U (spec «Мусор правее таблицы») — правее границы таблицы
assert column_letter(header.right_edge) == "S"
assert header.extra_cols == {}

View File

@ -0,0 +1,149 @@
"""Сборка `Sheet` из текстовой «картинки» для юнит-тестов детекторов, шапки, иерархии и классификации.
Лист описывается списком строк сверху вниз (первый элемент — строка 1), строка — списком ячеек слева направо (первый
элемент — колонка 1). Так тест читается как сама ведомость:
sheet = sheet_from_rows(
[
["ООО Ромашка"],
["Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."],
[],
[M("Счет", 2), M("Сальдо на начало", 2), M("Обороты за период", 2), M("Сальдо на конец", 2)],
[M("Контрагенты", 2), "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
[L(0), M("60", 2), 100, None, 50, 30, 120],
[L(1), M("ООО Поставщик", 2), C("100", bold=True)],
]
)
Значения ячеек:
- `None` или `""` — пустая ячейка (в `Sheet.cells` не попадает);
- `str` — текстовая ячейка как есть, **включая строку из пробелов** (`" "`): так тесты проверяют, что детекторы
считают её пустой, даже если читатель её сохранил (Review Focus 5); текст «1 234,00» остаётся текстом;
- `int` и `Decimal` — числовая ячейка (`Decimal`); `float` запрещён (деньги — `Decimal`, Global Constraints);
- `datetime.date` — дата, `bool` — логическое;
- `C(value, bold=..., indent=...)` — ячейка с оформлением (жирность, отступ) для каскада иерархии;
- `M(value, width, height=1)` — объединённая ячейка: значение в левой верхней, диапазон `width` колонок вправо и
`height` строк вниз. В своей строке `M` занимает `width` позиций — следующий элемент списка идёт в колонку правее
диапазона. В нижних строках диапазона на месте покрытых колонок пишите `None` (непустое значение там — ошибка
сборки: в Excel под merged-диапазоном значений нет);
- `L(level)` — только первым элементом строки: outline level Excel этой строки (0 — без группировки); сам `L`
колонку не занимает.
Разреженная строка — `at({1: "60", 5: 100, 17: 120})`: значения по номерам колонок, остальное пусто (удобно, когда
суммы стоят в далёких колонках). Её можно предварить уровнем: `[L(1), *at({...})]`.
`n_rows`/`n_cols` — по длине списка строк и самой длинной строке (с учётом ширины `M`), как габариты листа у читателя.
"""
import datetime
from collections.abc import Sequence
from dataclasses import dataclass
from decimal import Decimal
from src.parser.grid import Cell, MergedRange, Sheet
__all__ = ["C", "L", "M", "at", "sheet_from_rows"]
DEFAULT_SHEET_NAME = "Лист_1"
@dataclass(frozen=True)
class C:
"""Ячейка с оформлением: жирность и отступ (`alignment.indent` или ведущие пробелы у читателя)."""
value: object
bold: bool = False
indent: int = 0
@dataclass(frozen=True)
class M:
"""Объединённая ячейка: значение (или `C`) и размер диапазона в колонках и строках."""
value: object
width: int
height: int = 1
def __post_init__(self) -> None:
if self.width < 1 or self.height < 1:
raise ValueError(f"размер merged-диапазона должен быть ≥ 1, получено {self.width}x{self.height}")
@dataclass(frozen=True)
class L:
"""Outline level строки (первый элемент строки)."""
level: int
def at(values: dict[int, object]) -> list[object]:
"""Строка по номерам колонок (1-based): `at({1: "60", 5: 100})` == `["60", None, None, None, 100]`."""
if not values:
return []
if min(values) < 1:
raise ValueError("номера колонок 1-based")
return [values.get(col) for col in range(1, max(values) + 1)]
def _make_cell(value: object) -> Cell | None:
bold, indent = False, 0
if isinstance(value, C):
value, bold, indent = value.value, value.bold, value.indent
if value is None or value == "":
return None
if isinstance(value, bool):
return Cell(value, "bool", bold=bold, indent=indent)
if isinstance(value, float):
raise TypeError(f"float {value!r} в тестовой сетке: используйте int или Decimal")
if isinstance(value, int | Decimal):
return Cell(Decimal(value), "number", bold=bold, indent=indent)
if isinstance(value, datetime.date):
return Cell(value, "date", bold=bold, indent=indent)
if isinstance(value, str):
return Cell(value, "text", bold=bold, indent=indent)
raise TypeError(f"неподдерживаемое значение ячейки {value!r}")
def sheet_from_rows(rows: Sequence[Sequence[object]], name: str = DEFAULT_SHEET_NAME) -> Sheet:
"""`Sheet` по «картинке» строк (см. docstring модуля); номера строк и колонок — 1-based, как в Excel."""
cells: dict[tuple[int, int], Cell] = {}
merged: list[MergedRange] = []
covered: set[tuple[int, int]] = set() # ячейки под merged-диапазонами, кроме левой верхней
outline: dict[int, int] = {}
n_cols = 0
for row_number, row_items in enumerate(rows, start=1):
items = list(row_items)
if items and isinstance(items[0], L):
if items[0].level:
outline[row_number] = items[0].level
items = items[1:]
col = 1
for item in items:
if isinstance(item, L):
raise ValueError(f"строка {row_number}: L(level) допустим только первым элементом")
value, width, height = (item.value, item.width, item.height) if isinstance(item, M) else (item, 1, 1)
cell = _make_cell(value)
if (row_number, col) in covered:
if cell is not None:
raise ValueError(f"ячейка ({row_number}, {col}) лежит под merged-диапазоном, значение недопустимо")
elif cell is not None:
cells[(row_number, col)] = cell
if isinstance(item, M) and item.width * item.height > 1:
span = MergedRange(row_number, col, row_number + height - 1, col + width - 1)
merged.append(span)
covered.update(
(r, c)
for r in range(span.top, span.bottom + 1)
for c in range(span.left, span.right + 1)
if (r, c) != (span.top, span.left)
)
col += width
n_cols = max(n_cols, col - 1)
return Sheet(
name=name,
cells=cells,
merged=tuple(merged),
row_outline=outline,
n_rows=len(rows),
n_cols=n_cols,
)

View File

@ -0,0 +1,593 @@
"""Детекторы по содержимому (design D-2, spec «Листы ОСВ находятся по содержимому», «Заголовок таблицы и колонки
сумм определяются по тексту»): вид листа, блок заголовка, шесть колонок сумм, колонки текста и ИНН, extra, повтор
заголовка в данных. Сетки собираются `gridbuilder`; позиции в них произвольные — детектор их не знает заранее.
"""
from decimal import Decimal
import pytest
from gridbuilder import L, M, at, sheet_from_rows
from src.parser.detect import (
ColumnMap,
SheetKind,
TableHeader,
classify_sheet,
find_table_header,
first_data_row,
is_header_repeat,
)
from src.parser.errors import InvalidFormatError
from src.parser.grid import Cell, Sheet
from src.parser.rules import Rules, load_rules
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
OPENING = "Сальдо на начало периода"
TURNOVER = "Обороты за период"
CLOSING = "Сальдо на конец периода"
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def _doc_head() -> list[list[object]]:
"""Шапка варианта 1 (строки 1–5); заголовок таблицы — с строки 6."""
return [["АО «Ромашка»"], [TITLE], [], ["Выводимые данные: БУ (данные бухгалтерского учета)"], []]
def _rb_like() -> Sheet:
"""Вариант 1, заголовок в две строки (как АО РБ): суммы E/H, J/L, O/Q, подзаголовки merged на две строки."""
return sheet_from_rows(
[
*_doc_head(),
[M("Счет", 4), M(OPENING, 5), M(TURNOVER, 5), M(CLOSING, 3)], # 6
[M("Контрагенты", 4, 2), M("Дебет", 3, 2), M("Кредит", 2, 2), M("Дебет", 2, 2)]
+ [M("Кредит", 3, 2), M("Дебет", 2, 2), M("Кредит", 1, 2)], # 7
[], # 8 — под merged-подзаголовками
[L(0), *at({1: "60", 8: 300, 10: 1000, 12: 900, 17: 200})], # 9
[L(1), *at({1: "60.01", 8: 300, 10: 1000, 12: 900, 17: 200})],
[L(2), *at({1: "ООО Поставщик", 8: 300, 10: 1000, 12: 900, 17: 200})],
at({1: "Итого", 8: 300, 10: 1000, 12: 900, 17: 200}),
]
)
def _schastl_like() -> Sheet:
"""Вариант 1, три строки (как Счастливочная): Счет / Подразделение / Контрагенты + ИНН в E, суммы G/J, K/N, Q/S."""
return sheet_from_rows(
[
*_doc_head(),
[M("Счет", 6), M(OPENING, 4), M(TURNOVER, 6), M(CLOSING, 3)], # 6
[M("Подразделение", 6), M("Дебет", 3, 2), M("Кредит", 1, 2), M("Дебет", 3, 2)]
+ [M("Кредит", 3, 2), M("Дебет", 2, 2), M("Кредит", 1, 2)], # 7
[M("Контрагенты", 4), M("ИНН", 2)], # 8
[L(0), *at({1: "62", 7: 500, 11: 100, 14: 50, 17: 550})], # 9
[L(1), *at({1: "62.01", 7: 500, 11: 100, 14: 50, 17: 550})],
[L(2), *at({1: "БП Аренда", 7: 500, 11: 100, 14: 50, 17: 550})],
[L(3), *at({1: "МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО", 5: "3523023093", 7: 500, 11: 100, 14: 50, 17: 550})],
at({1: "Итого", 7: 500, 11: 100, 14: 50, 17: 550, 20: 777, 21: Decimal("0.5")}), # мусор правее S
]
)
# --- classify_sheet ---
def test_classify_osv_sheet_by_title_anywhere_in_first_rows(rules: Rules) -> None:
sheet = sheet_from_rows([[], [None, None, "ОБОРОТНО-САЛЬДОВАЯ\nведомость по счёту 60"]])
assert classify_sheet(sheet, rules) is SheetKind.OSV
@pytest.mark.parametrize("marker", ["Бухгалтерский баланс", "Отчет о финансовых результатах", "Форма по ОКУД 0710001"])
def test_classify_statement_sheet(rules: Rules, marker: str) -> None:
sheet = sheet_from_rows([["ООО Ромашка"], [marker]])
assert classify_sheet(sheet, rules) is SheetKind.STATEMENT
def test_classify_other_sheet(rules: Rules) -> None:
sheet = sheet_from_rows([["Справка"], ["Наименование", "Сумма"], ["Аренда", 100]])
assert classify_sheet(sheet, rules) is SheetKind.OTHER
def test_osv_marker_wins_over_statement_marker(rules: Rules) -> None:
sheet = sheet_from_rows([["Бухгалтерский баланс"], [TITLE]])
assert classify_sheet(sheet, rules) is SheetKind.OSV
def test_osv_marker_far_below_the_head_is_not_searched(rules: Rules) -> None:
sheet = sheet_from_rows([*([["строка"]] * 40), [TITLE]])
assert classify_sheet(sheet, rules) is SheetKind.OTHER
# --- find_table_header: варианты заголовка ---
def test_two_row_header_rb_like(rules: Rules) -> None:
sheet = _rb_like()
header = find_table_header(sheet, rules)
assert header.columns == ColumnMap(
opening_debit=5,
opening_credit=8,
turnover_debit=10,
turnover_credit=12,
closing_debit=15,
closing_credit=17,
collapsed=frozenset(),
)
assert header.columns.as_diagnostics() == {
"opening_debit": "E",
"opening_credit": "H",
"turnover_debit": "J",
"turnover_credit": "L",
"closing_debit": "O",
"closing_credit": "Q",
}
assert (header.first_row, header.last_row) == (6, 8)
assert header.text_col == 1
assert header.inn_col is None
assert [header.right_edge] == [17]
assert header.extra_cols == {}
assert header.group_label_rows == [6, 7]
assert [first_data_row(sheet, header)] == [9]
def test_three_row_header_with_inn_schastl_like(rules: Rules) -> None:
sheet = _schastl_like()
header = find_table_header(sheet, rules)
assert header.columns.as_diagnostics() == {
"opening_debit": "G",
"opening_credit": "J",
"turnover_debit": "K",
"turnover_credit": "N",
"closing_debit": "Q",
"closing_credit": "S",
}
assert [header.inn_col] == [5]
assert (header.first_row, header.last_row) == (6, 8)
assert header.group_label_rows == [6, 7, 8]
assert [first_data_row(sheet, header)] == [9]
def test_garbage_right_of_table_is_neither_sum_nor_extra(rules: Rules) -> None:
header = find_table_header(_schastl_like(), rules)
assert [header.right_edge] == [19]
assert header.extra_cols == {}
sums = {
header.columns.opening_debit,
header.columns.opening_credit,
header.columns.turnover_debit,
header.columns.turnover_credit,
header.columns.closing_debit,
header.columns.closing_credit,
}
assert not sums & {20, 21}
def test_four_row_header(rules: Rules) -> None:
sheet = sheet_from_rows(
[
*_doc_head(),
["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 6
["Подразделение", None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
["Контрагенты", "ИНН"],
["Договоры"], # 9
[L(0), *at({1: "62", 3: 10, 8: 10})],
[L(3), *at({1: "Договор 1", 3: 10, 8: 10})],
]
)
header = find_table_header(sheet, rules)
assert (header.first_row, header.last_row) == (6, 9)
assert header.group_label_rows == [6, 7, 8, 9]
assert [header.inn_col] == [2]
assert [header.columns.opening_debit] == [3]
assert [header.columns.closing_credit] == [8]
assert [first_data_row(sheet, header)] == [10]
def test_label_row_of_other_grouping_belongs_to_the_block(rules: Rules) -> None:
# «Основные средства» — группировка OTHER из словаря osv_groups: строка блока, а не первая строка данных
sheet = sheet_from_rows(
[
[TITLE],
["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Подразделение", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
["Основные средства"],
[L(0), *at({1: "01", 2: 10, 6: 10})],
]
)
header = find_table_header(sheet, rules)
assert header.group_label_rows == [2, 3, 4]
assert [first_data_row(sheet, header)] == [5]
def test_counterparty_named_like_a_grouping_right_after_header_is_data(rules: Rules) -> None:
# «Контрагенты-Сервис ООО» матчит метку «Контрагент…», но у неё суммы — это строка данных, а не часть блока
sheet = sheet_from_rows(
[
[TITLE],
["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
[L(1), *at({1: "Контрагенты-Сервис ООО", 2: 10, 6: 10})],
]
)
header = find_table_header(sheet, rules)
assert [header.last_row] == [3]
assert [first_data_row(sheet, header)] == [4]
def test_variant_2_subkonto_header(rules: Rules) -> None:
sheet = sheet_from_rows(
[
["Оборотно-сальдовая ведомость по счету 60"],
["Период: 1 квартал 2026 г."],
["Детализация по субсчетам, субконто: Контрагенты, Договоры"],
["Выводимые данные: сумма"],
["Субконто", M("Сальдо на начало периода", 2), M("Оборот за период", 2), M(CLOSING, 2)], # 5
[None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
[L(0), *at({1: "60", 2: 10, 7: 10})],
]
)
header = find_table_header(sheet, rules)
assert (header.first_row, header.last_row) == (5, 6)
assert header.group_label_rows == [5]
assert [header.columns.turnover_debit] == [4]
assert header.text_col == 1
def test_one_row_header_with_sides_in_group_cells(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[TITLE],
[
"Счет",
"Нач. сальдо Дт",
"Нач. сальдо Кт",
"Оборот Дт",
"Оборот Кт",
"Кон. сальдо Д-т",
"Кон. сальдо К-т",
],
[L(0), *at({1: "60", 2: 10, 7: 10})],
]
)
header = find_table_header(sheet, rules)
assert header.columns == ColumnMap(2, 3, 4, 5, 6, 7, frozenset())
assert (header.first_row, header.last_row) == (2, 2)
assert [first_data_row(sheet, header)] == [3]
def test_unmerged_group_titles_take_subheaders_up_to_the_next_group(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[TITLE],
["Счет", OPENING, None, TURNOVER, None, CLOSING],
[None, "Дт", "Кт", "Дт", "Кт", "Дт", "Кт"],
[L(0), *at({1: "60", 2: 10, 7: 10})],
]
)
header = find_table_header(sheet, rules)
assert header.columns == ColumnMap(2, 3, 4, 5, 6, 7, frozenset())
assert [header.right_edge] == [7]
def test_table_text_column_is_not_necessarily_the_first(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[TITLE],
[None, "Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
[None, "Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
[L(0), *at({2: "60", 3: 10, 8: 10})],
[L(1), *at({2: "ООО Поставщик", 3: 10, 8: 10})],
]
)
header = find_table_header(sheet, rules)
assert [header.text_col] == [2]
assert [header.columns.opening_debit] == [3]
assert header.group_label_rows == [2, 3]
# --- Без «начала», свёрнутое сальдо ---
def test_header_without_opening_group(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[TITLE],
["Счет", M(TURNOVER, 2), M(CLOSING, 2)],
["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит"],
[L(0), *at({1: "60", 2: 10, 5: 10})],
]
)
header = find_table_header(sheet, rules)
assert header.columns == ColumnMap(None, None, 2, 3, 4, 5, frozenset())
assert header.columns.as_diagnostics() == {
"turnover_debit": "B",
"turnover_credit": "C",
"closing_debit": "D",
"closing_credit": "E",
}
def test_collapsed_closing_balance_is_one_column_in_debit_slot(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[TITLE],
["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 1, 2)],
["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", None],
[L(0), *at({1: "60", 2: 10, 4: 5, 6: 15})],
[L(1), *at({1: "ООО Должник", 2: 10, 4: 5, 6: -15})],
]
)
header = find_table_header(sheet, rules)
assert [header.columns.closing_debit] == [6]
assert header.columns.closing_credit is None
assert header.columns.collapsed == frozenset({"closing"})
assert [header.right_edge] == [6]
assert header.columns.as_diagnostics()["closing_debit"] == "F"
def test_collapsed_group_with_empty_subheaders_under_wide_title(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[TITLE],
["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит"],
[L(0), *at({1: "60", 2: 10, 4: 5, 6: 15})],
]
)
header = find_table_header(sheet, rules)
assert header.columns.collapsed == frozenset({"opening"})
assert (header.columns.opening_debit, header.columns.opening_credit) == (2, None)
def test_collapsed_turnover_is_invalid_format(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[TITLE],
["Счет", M(OPENING, 2), M(TURNOVER, 1, 2), M(CLOSING, 2)],
["Контрагенты", "Дебет", "Кредит", None, "Дебет", "Кредит"],
[L(0), *at({1: "60", 2: 10, 6: 10})],
]
)
with pytest.raises(InvalidFormatError, match="обороты"):
find_table_header(sheet, rules)
def test_group_with_debit_but_no_credit_is_invalid_format(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[TITLE],
["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Итог"],
[L(0), *at({1: "60", 2: 10, 6: 10})],
]
)
with pytest.raises(InvalidFormatError, match=r"сальдо на конец.*Кредит"):
find_table_header(sheet, rules)
# --- Ошибки: нет заголовка ---
def test_no_table_header_is_invalid_format_naming_the_groups(rules: Rules) -> None:
sheet = sheet_from_rows([[TITLE], ["Наименование", "Сумма"], ["Аренда", 100]])
with pytest.raises(InvalidFormatError, match="сальдо на начало") as caught:
find_table_header(sheet, rules)
assert "обороты" in caught.value.message
assert "сальдо на конец" in caught.value.message
def test_single_group_is_not_a_header_and_missing_groups_are_named(rules: Rules) -> None:
sheet = sheet_from_rows([[TITLE], ["Счет", M(OPENING, 2)], [None, "Дебет", "Кредит"], ["60", 10]])
with pytest.raises(InvalidFormatError) as caught:
find_table_header(sheet, rules)
assert "обороты" in caught.value.message
assert "сальдо на конец" in caught.value.message
assert "сальдо на начало" not in caught.value.message
def test_header_without_text_column_left_of_sums_is_invalid_format(rules: Rules) -> None:
sheet = sheet_from_rows([[TITLE], [M(TURNOVER, 2), M(CLOSING, 2)], ["Дебет", "Кредит", "Дебет", "Кредит"], [1, 2]])
with pytest.raises(InvalidFormatError, match="текст"):
find_table_header(sheet, rules)
# --- ИНН и extra ---
def test_kpp_between_inn_and_sums_goes_to_extra(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[TITLE],
["Счет", None, None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 2
["Контрагенты", "ИНН", "КПП", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
[L(0), *at({1: "60", 4: 10, 9: 10})],
[L(1), *at({1: "ООО Поставщик", 2: "7701234567", 3: "770101001", 4: 10, 9: 10, 11: 99})],
]
)
header = find_table_header(sheet, rules)
assert [header.inn_col] == [2]
assert header.extra_cols == {3: "КПП"}
assert [header.columns.opening_debit] == [4]
assert [header.right_edge] == [9]
def test_unnamed_column_with_data_is_extra_under_its_letter(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[TITLE],
["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Контрагенты", None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
[L(0), *at({1: "60", 2: "прим.", 3: 10, 8: 10})],
]
)
header = find_table_header(sheet, rules)
assert header.extra_cols == {2: "B"}
def test_inn_column_found_by_digits_without_header(rules: Rules) -> None:
rows: list[list[object]] = [
[TITLE],
["Счет", None, None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
[L(0), *at({1: "60", 4: 10, 9: 10})],
]
inns: list[object] = ["7701234567", Decimal("3523023093"), "770123456789", "7701234567", "не указан"]
for index, inn in enumerate(inns):
rows.append([L(1), *at({1: f"Контрагент {index}", 2: inn, 3: f"код {index}", 4: 1, 9: 1})])
header = find_table_header(sheet_from_rows(rows), rules)
assert [header.inn_col] == [2] # 4 из 5 непустых — 10/12 цифр (80 %)
assert header.extra_cols == {3: "C"}
def test_mostly_non_inn_digits_are_not_an_inn_column(rules: Rules) -> None:
rows: list[list[object]] = [
[TITLE],
["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Контрагенты", "Код", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
]
for index, code in enumerate(["7701234567", "12", "345", "7701234567", "000123"]):
rows.append([L(1), *at({1: f"Контрагент {index}", 2: code, 3: 1, 8: 1})])
header = find_table_header(sheet_from_rows(rows), rules)
assert header.inn_col is None
assert header.extra_cols == {2: "Код"}
# --- Пустые строки и повтор заголовка (Review Focus 4, 5) ---
def test_whitespace_rows_after_header_are_empty(rules: Rules) -> None:
sheet = sheet_from_rows(
[
[TITLE],
["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
[" ", "\u00a0", None, " "],
[],
[L(0), *at({1: "60", 2: 10, 7: 10})],
]
)
header = find_table_header(sheet, rules)
assert [header.last_row] == [3]
assert [first_data_row(sheet, header)] == [6]
def test_first_data_row_of_table_without_data_is_past_the_sheet(rules: Rules) -> None:
sheet = sheet_from_rows(
[[TITLE], ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", *(["Дебет", "Кредит"] * 3)]]
)
header = find_table_header(sheet, rules)
assert first_data_row(sheet, header) == sheet.n_rows + 1
def _with_repeated_header() -> Sheet:
group_row = ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)]
side_row = ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"]
return sheet_from_rows(
[
[TITLE],
group_row, # 2
side_row, # 3
[L(0), *at({1: "60", 2: 30, 7: 30})], # 4
[L(1), *at({1: "ООО Первый", 2: 10, 7: 10})], # 5
[], # 6
group_row, # 7 — повтор на «странице» печати
side_row, # 8
[L(1), *at({1: "ООО Второй", 2: 20, 7: 20})], # 9
["Сальдо на начало периода", "Обороты за период", None, None, 5], # 10 — повтор без merged
[L(1), *at({1: "Контрагенты", 2: 1, 7: 1})], # 11 — контрагент с таким именем, но с суммами
]
)
def test_header_repeat_inside_data_is_recognised(rules: Rules) -> None:
sheet = _with_repeated_header()
header = find_table_header(sheet, rules)
assert is_header_repeat(sheet, 7, header, rules)
assert is_header_repeat(sheet, 8, header, rules)
assert is_header_repeat(sheet, 10, header, rules)
@pytest.mark.parametrize("row", [4, 5, 6, 9, 11])
def test_data_and_empty_rows_are_not_header_repeats(rules: Rules, row: int) -> None:
sheet = _with_repeated_header()
header = find_table_header(sheet, rules)
assert not is_header_repeat(sheet, row, header, rules)
def test_header_is_found_from_the_top(rules: Rules) -> None:
sheet = _with_repeated_header()
header: TableHeader = find_table_header(sheet, rules)
assert [header.first_row] == [2]
def test_row_with_only_cells_right_of_table_is_not_a_header_repeat(rules: Rules) -> None:
# В блоке rb-подобного заголовка есть пустая строка (под merged-подзаголовками); строка, где есть только «хвост»
# правее таблицы, после отсечения по right_edge тоже пуста — но это не повтор заголовка
base = _rb_like()
sheet = Sheet(
name=base.name,
cells={**base.cells, (base.n_rows + 1, 19): Cell(Decimal("375490642.46"), "number")},
merged=base.merged,
row_outline=base.row_outline,
n_rows=base.n_rows + 1,
n_cols=19,
)
header = find_table_header(sheet, rules)
assert not is_header_repeat(sheet, sheet.n_rows, header, rules)

View File

@ -0,0 +1,56 @@
"""Хелпер тестов `gridbuilder`: «картинка» строк превращается в `Sheet` так же, как его видят детекторы."""
from decimal import Decimal
import pytest
from gridbuilder import C, L, M, at, sheet_from_rows
from src.parser.grid import MergedRange
def test_rows_and_columns_are_one_based_and_values_typed() -> None:
sheet = sheet_from_rows([["Счет", 100, Decimal("1.50")], [], [None, "x"]])
assert sheet.cell(1, 1).text == "Счет"
assert sheet.cell(1, 2).value == Decimal(100)
assert sheet.cell(1, 2).kind == "number"
assert sheet.cell(1, 3).value == Decimal("1.50")
assert sheet.cell(3, 2).text == "x"
assert (sheet.n_rows, sheet.n_cols) == (3, 3)
def test_merged_cell_occupies_its_width_and_covers_rows_below() -> None:
sheet = sheet_from_rows([[M("Сальдо", 2, 2), "Обороты"], [None, None, "Дебет"]])
assert sheet.merged == (MergedRange(1, 1, 2, 2),)
assert sheet.cell(1, 3).text == "Обороты"
assert sheet.text_at(2, 2) == "Сальдо"
assert sheet.cell(2, 3).text == "Дебет"
def test_value_under_merged_range_is_rejected() -> None:
with pytest.raises(ValueError, match="merged"):
sheet_from_rows([[M("Сальдо", 1, 2)], ["лишнее"]])
def test_outline_style_and_sparse_rows() -> None:
sheet = sheet_from_rows([[L(2), C("60.01", bold=True, indent=3)], at({4: 7})])
assert [sheet.outline(1)] == [2]
assert sheet.outline(2) == 0
assert sheet.cell(1, 1).bold
assert [sheet.cell(1, 1).indent] == [3]
assert sheet.cell(2, 4).value == Decimal(7)
assert [sheet.n_cols] == [4]
def test_whitespace_text_is_kept_as_a_cell_but_row_is_empty_for_detectors() -> None:
sheet = sheet_from_rows([[" ", "\u00a0"]])
assert (1, 1) in sheet.cells
assert sheet.is_row_empty(1)
def test_float_is_rejected() -> None:
with pytest.raises(TypeError, match="float"):
sheet_from_rows([[0.1]])

View File

@ -93,6 +93,15 @@ def test_pattern_returns_match_object_for_position_arithmetic() -> None:
assert match.string[match.end() :].strip().startswith("58.03")
def test_search_normalized_matches_prenormalized_text_without_renormalizing() -> None:
pattern = Pattern.compile("оборотно сальдовая ведомость")
normalized = normalize_text("ОБОРОТНО-САЛЬДОВАЯ\nВедомость")
assert pattern.search_normalized(normalized) is not None
# Текст не нормализуется повторно: «сырая» строка с дефисом и заглавными не совпадает
assert pattern.search_normalized("ОБОРОТНО-САЛЬДОВАЯ ведомость") is None
@pytest.mark.parametrize("bad", ["сальдо на начало-периода", "счёт", "сальдо\nна", "[а-я]+"])
def test_pattern_rejects_sources_that_normalization_would_never_match(bad: str) -> None:
with pytest.raises(ValueError, match="нормализ"):
@ -336,6 +345,23 @@ def test_group_type_mapping(rules: Rules, text: str, expected: str) -> None:
assert rules.groups.classify(text) == expected
@pytest.mark.parametrize(
"text",
[
"Основные средства",
"Номенклатура",
"Статьи затрат",
"Статьи движения денежных средств",
"Валюты",
"Нематериальные активы",
],
)
def test_known_other_groupings_are_listed_explicitly(rules: Rules, text: str) -> None:
# Явная запись OTHER (а не `default`) нужна детектору: строка блока заголовка с таким текстом — метка группировки,
# а не первая строка данных (реальная формулировка «Основные средства» — ОСВ по счету 01)
assert first_match(rules.groups.mapping, text) == "OTHER"
def test_group_mapping_is_ordered_pairs_and_first_match_wins() -> None:
rules = OsvGroupRules.model_validate(
{"mapping": [["^договор", "CONTRACT"], ["договор", "OTHER"]], "default": "ACCOUNT"}

View File

@ -219,9 +219,9 @@ def is_header_repeat(sheet: Sheet, row: int, header: TableHeader, rules: Rules)
```
Алгоритм `find_table_header`: для каждой непустой строки r (сверху) собрать тексты ячеек с учётом merged; строка-кандидат — где найдены ≥ 2 из трёх групп (`groups`), при этом текст группы лежит в merged-диапазоне или одиночной ячейке → `ColumnSpan(left, right)`. Для каждой группы искать «дебет»/«кредит» в строках r+1..r+3 внутри span (origin merged); найдены оба → индексы; найдена одна колонка без подзаголовков (span ширины 1 или пустые подзаголовки) → свёрнутое сальдо (`collapsed`); группа «turnover» свёрнутой быть не может → `InvalidFormatError`. `last_row` = максимум строк, где встретились подзаголовки или метки группировок (ячейки text_col до первой строки данных: строка считается частью блока, если её текст матчит `osv_groups` или `service_columns.inn` или `header_markers.subkonto`). `text_col` = минимальный col среди непустых ячеек первых 20 строк данных, лежащий левее `min(всех индексов сумм)`. `inn_col`: колонка между text_col и первой суммой с заголовком `inn` в блоке, иначе колонка, где ≥ 80 % непустых значений первых 200 строк данных — 10/12 цифр. `extra_cols`: прочие колонки между text_col и right_edge, не входящие в суммы/ИНН, у которых есть хоть одно непустое значение в данных; имя — текст заголовка в блоке над колонкой или буква. `classify_sheet`: OSV если любая ячейка (первые 30 строк) матчит `sheet_markers`; иначе STATEMENT если матчит `statement_markers`; иначе OTHER.
- [ ] **Step 1: Тесты.** `gridbuilder.sheet_from_rows([...])` строит `Sheet` из списка строк (значения, `M(text, width)` для merged, `L(level)` для outline). Юнит-сценарии: заголовок 2 строки (вариант 1 РБ), 3 строки с `ИНН` (Счастливочная), 4 строки, вариант 2 («Субконто»), без «начала», свёрнутое «конец», «Обороты» свёрнутые → `InvalidFormatError`, КПП между ИНН и суммами → `extra_cols`, мусор правее `right_edge` не в `extra_cols`, повтор заголовка в данных → `is_header_repeat`, `classify_sheet` для ОСВ / ББ / прочего. `test_detect_real.py` (`fixtures`): для всех 66 — `find_table_header` без ошибок, все шесть индексов заданы, `collapsed` пуст; для `rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx` диагностика `E/H/J/L/O/Q`, `inn_col is None`; для `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls` — `G/J/K/N/Q/S`, `inn_col` = E (5), `group_label_rows` длиной 3.
- [ ] **Step 2–4:** падает → реализация → тесты, `make check`.
- [ ] **Step 5: Commit** `feat(parser): детекторы листов ОСВ, заголовка таблицы и колонок по содержимому`.
- [x] **Step 1: Тесты.** `gridbuilder.sheet_from_rows([...])` строит `Sheet` из списка строк (значения, `M(text, width)` для merged, `L(level)` для outline). Юнит-сценарии: заголовок 2 строки (вариант 1 РБ), 3 строки с `ИНН` (Счастливочная), 4 строки, вариант 2 («Субконто»), без «начала», свёрнутое «конец», «Обороты» свёрнутые → `InvalidFormatError`, КПП между ИНН и суммами → `extra_cols`, мусор правее `right_edge` не в `extra_cols`, повтор заголовка в данных → `is_header_repeat`, `classify_sheet` для ОСВ / ББ / прочего. `test_detect_real.py` (`fixtures`): для всех 66 — `find_table_header` без ошибок, все шесть индексов заданы, `collapsed` пуст; для `rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx` диагностика `E/H/J/L/O/Q`, `inn_col is None`; для `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls` — `G/J/K/N/Q/S`, `inn_col` = E (5), `group_label_rows` длиной 3.
- [x] **Step 2–4:** падает → реализация → тесты, `make check`.
- [x] **Step 5: Commit** `feat(parser): детекторы листов ОСВ, заголовка таблицы и колонок по содержимому`.
---

View File

@ -25,7 +25,7 @@
## 2. Обнаружение структуры
- [ ] 2.1 `detect.py`: `find_osv_sheets`, `find_table_header` (группы по merged-тексту в 1–4 строках, Дебет/Кредит под
- [x] 2.1 `detect.py`: `find_osv_sheets`, `find_table_header` (группы по merged-тексту в 1–4 строках, Дебет/Кредит под
группой, свёрнутое сальдо, отсутствие «начала», `right_edge`, `text_col`, `inn_col` по заголовку и по доле 10/12 цифр,
`extra`-колонки). Unit-тесты на сетках: заголовок 1/2/3/4 строки, merged, без «начала», свёрнутое сальдо, КПП между
ИНН и суммами, мусор правее. `fixtures`-тест: `columns` для РБ `E/H/J/L/O/Q`, для Счастливочной xls `G/J/K/N/Q/S` + `inn=E`.