statement_money вместо to_money в разборе строк форм, предупреждения о неразобранном формате и об xlsb, сравнение условий формата с точностью Excel (15 значащих цифр), тесты и тексты. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
8.2 KiB
Python
159 lines
8.2 KiB
Python
"""Сборка `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 dataclasses
|
||
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,
|
||
)
|
||
|
||
|
||
def with_formats(sheet: Sheet, formats: dict[tuple[int, int], str]) -> Sheet:
|
||
"""Копия листа, где числовым ячейкам (row, col) проставлен числовой формат (#42)."""
|
||
cells = dict(sheet.cells)
|
||
for position, number_format in formats.items():
|
||
cells[position] = dataclasses.replace(cells[position], number_format=number_format)
|
||
return dataclasses.replace(sheet, cells=cells)
|