Andrey Epifancev a833945669 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
2026-10-05 12:54:11 +04:00

150 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сборка `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,
)