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:
parent
fcd609c633
commit
a833945669
@ -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` при
|
||||
загрузке с именем файла и ключом.
|
||||
|
||||
|
||||
@ -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
452
api/src/parser/detect.py
Normal 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)
|
||||
)
|
||||
@ -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
103
api/tests/fixtures/test_detect_real.py
vendored
Normal 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 == {}
|
||||
149
api/tests/unit/parser/gridbuilder.py
Normal file
149
api/tests/unit/parser/gridbuilder.py
Normal 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,
|
||||
)
|
||||
593
api/tests/unit/parser/test_detect.py
Normal file
593
api/tests/unit/parser/test_detect.py
Normal 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)
|
||||
56
api/tests/unit/parser/test_gridbuilder.py
Normal file
56
api/tests/unit/parser/test_gridbuilder.py
Normal 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]])
|
||||
@ -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"}
|
||||
|
||||
@ -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): детекторы листов ОСВ, заголовка таблицы и колонок по содержимому`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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`.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user