diff --git a/api/rules/README.md b/api/rules/README.md index c2d33c7..a79c56d 100644 --- a/api/rules/README.md +++ b/api/rules/README.md @@ -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` при загрузке с именем файла и ключом. diff --git a/api/rules/osv_groups.yaml b/api/rules/osv_groups.yaml index 88354f1..8691bb6 100644 --- a/api/rules/osv_groups.yaml +++ b/api/rules/osv_groups.yaml @@ -5,6 +5,13 @@ mapping: - ['^подразделени', DEPARTMENT] - ['^контрагент', COUNTERPARTY] - ['^договор', CONTRACT] + # Известные группировки OTHER перечислены явно: по ним детектор узнаёт строку-метку в блоке заголовка таблицы + # (строка «Основные средства» под «Счет / Подразделение» — часть заголовка, а не первая строка данных) + - ['^основн\w*\s+средств', OTHER] + - ['^нематериальн\w*\s+актив', OTHER] + - ['^номенклатур', OTHER] + - ['^стать(и|я)\b', OTHER] + - ['^валют', OTHER] -# Всё прочее («Валюта», «Номенклатура», «Статьи затрат», «Основные средства», …) +# Всё прочее (не перечисленное выше); такая группировка в отдельной строке блока заголовка не будет узнана как метка default: OTHER diff --git a/api/src/parser/detect.py b/api/src/parser/detect.py new file mode 100644 index 0000000..38d738f --- /dev/null +++ b/api/src/parser/detect.py @@ -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) + ) diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 3c71cc1..1aba286 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -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})" diff --git a/api/tests/fixtures/test_detect_real.py b/api/tests/fixtures/test_detect_real.py new file mode 100644 index 0000000..c97c7d8 --- /dev/null +++ b/api/tests/fixtures/test_detect_real.py @@ -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 == {} diff --git a/api/tests/unit/parser/gridbuilder.py b/api/tests/unit/parser/gridbuilder.py new file mode 100644 index 0000000..d6da674 --- /dev/null +++ b/api/tests/unit/parser/gridbuilder.py @@ -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, + ) diff --git a/api/tests/unit/parser/test_detect.py b/api/tests/unit/parser/test_detect.py new file mode 100644 index 0000000..802180e --- /dev/null +++ b/api/tests/unit/parser/test_detect.py @@ -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) diff --git a/api/tests/unit/parser/test_gridbuilder.py b/api/tests/unit/parser/test_gridbuilder.py new file mode 100644 index 0000000..db3f217 --- /dev/null +++ b/api/tests/unit/parser/test_gridbuilder.py @@ -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]]) diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index c12b6a6..1986b7c 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -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"} diff --git a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md index 5070dd5..4c01d16 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md +++ b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md @@ -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): детекторы листов ОСВ, заголовка таблицы и колонок по содержимому`. --- diff --git a/openspec/changes/slice-2a-osv-parser/tasks.md b/openspec/changes/slice-2a-osv-parser/tasks.md index 7674340..b7257c8 100644 --- a/openspec/changes/slice-2a-osv-parser/tasks.md +++ b/openspec/changes/slice-2a-osv-parser/tasks.md @@ -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`.