test: генератор синтетики и сценарии спеки на синтетических файлах

api/tests/synthetic: детерминированный генератор s01…s13 (openpyxl, метки времени книги и zip зафиксированы),
минимальный писатель BIFF12 для s13.xlsb (ruling R-9, читается pyxlsb), тесты сценариев спеки osv-parsing на файлах
из fixtures/osv/synthetic/ (таблица «файл → ожидания», s13 равен s02 кроме предупреждения xlsb), тест побайтовой
воспроизводимости генерации. synthetic_files() учитывает только файлы Excel (README.md каталога не файл набора).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 15:04:50 +04:00
parent 621c0b2dc4
commit e1559c9770
9 changed files with 1290 additions and 6 deletions

View File

@ -38,8 +38,13 @@ def osv_fixture_files(root: Path) -> list[Path]:
def synthetic_files(root: Path) -> list[Path]:
"""Файлы `root/osv/synthetic/*` (скрытые, вроде `.gitkeep`, не считаются); отсортированы."""
"""Файлы Excel `root/osv/synthetic/*.xls|xlsx|xlsb` (скрытые и пояснения вроде `README.md` не считаются);
отсортированы."""
directory = root / "osv" / _SYNTHETIC
if not directory.is_dir():
return []
return sorted(path for path in directory.iterdir() if path.is_file() and not path.name.startswith("."))
return sorted(
path
for path in directory.iterdir()
if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in OSV_EXTENSIONS
)

View File

@ -0,0 +1 @@
"""Синтетические ОСВ для форматов, которых нет в реальных файлах: генератор и тесты сценариев спеки."""

View File

@ -0,0 +1,600 @@
"""Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`.
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл (см. `osv/synthetic/README.md` в репозитории
fixtures). Генератор самодостаточен (openpyxl + `xlsb_writer`) и детерминирован: повторный запуск даёт те же байты —
метки времени книги и zip постоянны, порядок записи фиксирован.
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
строк в файлах разные: парсер не должен на них полагаться.
"""
import argparse
import datetime
import io
import re
import sys
import zipfile
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from decimal import Decimal
from pathlib import Path
from typing import Literal
import openpyxl
from openpyxl.styles import Alignment, Font
from tests.synthetic.xlsb_writer import write_xlsb
__all__ = ["FILE_NAMES", "generate_all", "main"]
FIXED_TIME = datetime.datetime(2026, 1, 1, 0, 0, 0)
ZIP_DATE_TIME = (2026, 1, 1, 0, 0, 0)
CREATOR = "arm-ka synthetic"
SHEET = "Лист_1"
ORG = 'ООО "Тестовая организация"'
D = Decimal
ZERO = D(0)
INDENT_STEP = " " # ведущие пробелы одного уровня, когда отступ ячейки недоступен (xlsb)
Money = D | None
Pair = tuple[Money, Money]
Six = tuple[D, D, D, D, D, D]
Hierarchy = Literal["outline", "indent", "spaces", "none"]
NumStyle = Literal["num", "text", "text_nbsp", "text_narrow"]
# --- Дерево ведомости ---
@dataclass
class Node:
"""Строка ведомости. Лист: свои начало и обороты, конец вычисляется; родитель: сумма детей по колонкам.
`indicator` — строка «Вал.»/«Кол.»/«USD» под контрагентом: свои суммы, в сумму родителя не входит.
"""
text: str
kids: list["Node"] = field(default_factory=list)
inn: str | None = None
kpp: str | None = None
opening: Pair = (None, None)
turnover: Pair = (None, None)
indicator: bool = False
num_style: NumStyle = "num"
closing_debit_delta: D = ZERO # порча суммы (s10): к записанному `closing_debit` прибавляется, родитель не меняется
def children(self) -> list["Node"]:
return [kid for kid in self.kids if not kid.indicator]
def six(self) -> Six:
"""Шесть сумм по колонкам: начало Д/К, обороты Д/К, конец Д/К (без порчи)."""
children = self.children()
if children:
parts = [kid.six() for kid in children]
return tuple(sum((part[i] for part in parts), ZERO) for i in range(6)) # type: ignore[return-value]
opening_debit, opening_credit = (value or ZERO for value in self.opening)
turnover_debit, turnover_credit = (value or ZERO for value in self.turnover)
net = opening_debit - opening_credit + turnover_debit - turnover_credit
return (
opening_debit,
opening_credit,
turnover_debit,
turnover_credit,
net if net > 0 else ZERO,
-net if net < 0 else ZERO,
)
def flatten(node: Node, depth: int = 0) -> list[tuple[Node, int]]:
"""Строки в порядке обхода в глубину: узел, его глубина."""
found = [(node, depth)]
for kid in node.kids:
found.extend(flatten(kid, depth + 1))
return found
def leaf(text: str, opening: Pair, turnover: Pair, **kwargs: object) -> Node:
return Node(text, opening=opening, turnover=turnover, **kwargs) # type: ignore[arg-type]
def money(value: str | None) -> Money:
return None if value is None else D(value)
def pair(debit: str | None, credit: str | None) -> Pair:
return money(debit), money(credit)
def alfa(**kwargs: object) -> Node:
return leaf("ООО Альфа", pair(None, "250000.50"), pair("180000.25", "420000.00"), inn="7701234567", **kwargs)
def beta(**kwargs: object) -> Node:
return leaf("ООО Бета", pair("15000.00", None), pair("90000.00", "40000.00"), inn="7707654321", **kwargs)
def ivanov() -> Node:
return leaf("Иванов Иван", pair(None, "3000.00"), pair("3000.00", None)) # физлицо: без ИНН
def gamma(**kwargs: object) -> Node:
return leaf("ООО Гамма", pair("120000.00", None), pair("30000.00", "100000.00"), inn="5001234567", **kwargs)
def delta(**kwargs: object) -> Node:
return leaf("ООО Дельта", pair(None, "8000.10"), pair("8000.10", "12000.35"), inn="7812345678", **kwargs)
def sidorov() -> Node:
return leaf("ИП Сидоров", pair(None, "1500.00"), pair(None, None), inn="500100732259") # ИНН из 12 цифр
KPP = {"ООО Альфа": "770101001", "ООО Бета": "770701001", "ООО Гамма": "500101001", "ООО Дельта": "781201001"}
def base_tree(account: str = "60", *, kpp: bool = False) -> Node:
"""Счёт 60: два субсчёта, шесть контрагентов (с ИНН из 10 и 12 цифр и физлицо без ИНН)."""
kids = {"60.01": [alfa(), beta(), ivanov()], "60.02": [gamma(), delta(), sidorov()]}
if kpp:
for group in kids.values():
for node in group:
node.kpp = KPP.get(node.text)
return Node(account, [Node(sub, items) for sub, items in kids.items()])
def receivables_tree() -> Node:
"""Счёт 62 для второго листа s06."""
first = leaf("ООО Покупатель-1", pair("500000.00", None), pair("320000.00", "410000.00"), inn="7728123456")
second = leaf("ООО Покупатель-2", pair(None, "20000.00"), pair("75000.00", "55000.00"), inn="7714987654")
return Node("62", [Node("62.01", [first, second])])
# --- Лист в памяти ---
@dataclass
class CellSpec:
value: str | int | D
bold: bool = False
indent: int = 0
@dataclass
class SheetSpec:
name: str
cells: dict[tuple[int, int], CellSpec] = field(default_factory=dict)
merges: list[tuple[int, int, int, int]] = field(default_factory=list)
outline: dict[int, int] = field(default_factory=dict)
def put(self, row: int, col: int, value: str | int | D, *, bold: bool = False, indent: int = 0) -> None:
self.cells[(row, col)] = CellSpec(value, bold, indent)
def merge(self, top: int, left: int, bottom: int, right: int) -> None:
if (top, left) != (bottom, right):
self.merges.append((top, left, bottom, right))
# --- Шапка и таблица ---
@dataclass
class OsvSpec:
"""Параметры листа ОСВ: шапка, раскладка колонок и способ выражения иерархии."""
account: str = "60"
period: str = "1 квартал 2025 г."
data_type_line: str = "Выводимые данные: БУ (данные бухгалтерского учета)"
currency_line: str = "Валюта БУ, НУ: RUB"
variant: Literal[1, 2] = 1
inn_col: int | None = 5
kpp_col: int | None = None
first_sum_col: int = 6
opening: bool = True
collapsed_closing: bool = False
hierarchy: Hierarchy = "outline"
bold_accounts: bool = False
repeat_header_after: int | None = None # после стольких строк данных повторяется заголовок (печать по страницам)
detail: str = "Контрагенты, Договоры"
GROUP_TITLES = {
"opening": "Сальдо на начало периода",
"turnover": "Обороты за период",
"closing": "Сальдо на конец периода",
}
PAIR_WIDTH = 2 # колонки группы сумм с подзаголовками «Дебет»/«Кредит»
DEBIT, CREDIT = "Дебет", "Кредит"
TOTAL_TEXT = "Итого"
def _groups(spec: OsvSpec) -> list[tuple[str, int, int]]:
"""Группы сумм слева направо: (ключ, первая колонка, ширина)."""
result: list[tuple[str, int, int]] = []
col = spec.first_sum_col
for key in ("opening", "turnover", "closing"):
if key == "opening" and not spec.opening:
continue
width = 1 if key == "closing" and spec.collapsed_closing else PAIR_WIDTH
result.append((key, col, width))
col += width
return result
def _header_rows(sheet: SheetSpec, spec: OsvSpec, top: int, label: str, sub_label: str) -> int:
"""Блок заголовка таблицы с `top`: строка групп и строка Дебет/Кредит; возвращает номер следующей строки."""
sheet.put(top, 1, label)
sheet.put(top + 1, 1, sub_label)
if spec.inn_col is not None:
sheet.put(top + 1, spec.inn_col, "ИНН")
if spec.kpp_col is not None:
sheet.put(top + 1, spec.kpp_col, "КПП")
for key, col, width in _groups(spec):
sheet.put(top, col, GROUP_TITLES[key])
sheet.merge(top, col, top, col + width - 1)
if width == PAIR_WIDTH:
sheet.put(top + 1, col, DEBIT)
sheet.put(top + 1, col + 1, CREDIT)
return top + 2
def _header_v1(sheet: SheetSpec, spec: OsvSpec) -> int:
sheet.put(1, 1, ORG)
sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {spec.account} за {spec.period}")
sheet.put(4, 1, spec.data_type_line)
sheet.put(7, 1, "Параметры:")
sheet.put(7, 3, spec.currency_line)
return _header_rows(sheet, spec, 9, "Счет", "Контрагенты")
def _header_v2(sheet: SheetSpec, spec: OsvSpec) -> int:
sheet.put(1, 1, ORG)
sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {spec.account}")
sheet.put(3, 1, f"Период: {spec.period}")
sheet.put(4, 1, f"Детализация по субсчетам, субконто: {spec.detail}")
sheet.put(5, 1, spec.data_type_line)
top = 7
nxt = _header_rows(sheet, spec, top, "Субконто", "")
sheet.cells.pop((top + 1, 1), None)
sheet.merge(top, 1, top + 1, 1)
return nxt
def _row_cells(sheet: SheetSpec, spec: OsvSpec, row: int, entry: tuple[Node, int], six: Six) -> None:
"""Одна строка данных (`entry` — узел и его глубина): текст с оформлением иерархии, ИНН/КПП и суммы."""
node, depth = entry
mode = spec.hierarchy
text = INDENT_STEP * depth + node.text if mode == "spaces" else node.text
bold = spec.bold_accounts and bool(node.children())
sheet.put(row, 1, text, bold=bold, indent=depth if mode == "indent" else 0)
if mode == "outline" and depth:
sheet.outline[row] = depth
if spec.inn_col is not None and node.inn:
sheet.put(row, spec.inn_col, node.inn)
if spec.kpp_col is not None and node.kpp:
sheet.put(row, spec.kpp_col, node.kpp)
values = list(six)
values[4] += node.closing_debit_delta
for key, col, width in _groups(spec):
index = {"opening": 0, "turnover": 2, "closing": 4}[key]
debit, credit = values[index], values[index + 1]
if width == 1: # свёрнутое сальдо: одна колонка, кредитовое — со знаком «минус»
_put_number(sheet, row, col, debit - credit, node.num_style)
else:
_put_number(sheet, row, col, debit, node.num_style)
_put_number(sheet, row, col + 1, credit, node.num_style)
NBSP = chr(0x00A0) # неразрывный пробел
NARROW_NBSP = chr(0x202F) # узкий неразрывный пробел
_SEPARATORS: dict[str, str] = {"text": " ", "text_nbsp": NBSP, "text_narrow": NARROW_NBSP}
_DASHES: dict[str, str] = {"text": "—", "text_nbsp": "–", "text_narrow": "-"}
def _text_number(value: D, style: str) -> str:
"""Число как печатает человек: «1 234 567,89», «(1 234,00)» для отрицательных, прочерк для нуля."""
if value == 0:
return _DASHES[style]
integer, fraction = f"{abs(value):.2f}".split(".")
groups: list[str] = []
while integer:
groups.insert(0, integer[-3:])
integer = integer[:-3]
body = f"{_SEPARATORS[style].join(groups)},{fraction}"
return f"({body})" if value < 0 else body
def _put_number(sheet: SheetSpec, row: int, col: int, value: D, style: str) -> None:
if style == "num":
if value != 0:
sheet.put(row, col, value)
return
sheet.put(row, col, _text_number(value, style))
def build_osv(spec: OsvSpec, root: Node, name: str = SHEET) -> SheetSpec:
"""Лист ОСВ по дереву `root` и параметрам `spec`."""
sheet = SheetSpec(name)
next_row = _header_v1(sheet, spec) if spec.variant == 1 else _header_v2(sheet, spec)
for index, (node, depth) in enumerate(flatten(root)):
if spec.repeat_header_after == index:
# Печать по страницам: пустая строка и копия блока заголовка
next_row = _header_rows(sheet, spec, next_row + 1, "Счет", "Контрагенты")
_row_cells(sheet, spec, next_row, (node, depth), node.six())
next_row += 1
total = SheetSpec("")
_row_cells(total, spec, 1, (Node(TOTAL_TEXT), 0), root.six())
for (_, col), cell in total.cells.items():
sheet.cells[(next_row, col)] = CellSpec(cell.value, bold=spec.bold_accounts)
return sheet
# --- Прочие листы ---
def reference_sheet(name: str = "Справка") -> SheetSpec:
"""Посторонний лист: таблица без признаков ОСВ и форм."""
sheet = SheetSpec(name)
sheet.put(1, 1, "Справка о составе группы")
sheet.put(3, 1, "Организация")
sheet.put(3, 2, "ИНН")
sheet.put(3, 3, "Доля, %")
sheet.put(4, 1, 'ООО "Тестовая организация"')
sheet.put(4, 2, "7700000001")
sheet.put(4, 3, 100)
sheet.put(5, 1, "ООО Дочерняя")
sheet.put(5, 2, "7700000002")
sheet.put(5, 3, 51)
return sheet
def statement_sheet(name: str = "Бухгалтерский баланс") -> SheetSpec:
"""Лист формы ББ (признаки «Бухгалтерский баланс» и код формы 0710001): ОСВ-парсер его пропускает."""
sheet = SheetSpec(name)
sheet.put(1, 1, "Бухгалтерский баланс")
sheet.put(2, 1, "на 30 сентября 2025 г.")
sheet.put(3, 1, "Форма по ОКУД 0710001")
sheet.put(5, 1, "Актив")
sheet.put(5, 2, "Код")
sheet.put(5, 3, "На 30.09.2025")
sheet.put(6, 1, "Основные средства")
sheet.put(6, 2, "1150")
sheet.put(6, 3, 1200000)
sheet.put(7, 1, "Запасы")
sheet.put(7, 2, "1210")
sheet.put(7, 3, 340000)
return sheet
# --- Файлы ---
def _s01() -> list[SheetSpec]:
def contract(text: str, opening: Pair, turnover: Pair) -> Node:
return leaf(text, opening, turnover)
first = Node(
"ООО Альфа",
[
contract("Договор поставки № 15 от 10.01.2026", pair(None, "100000.00"), pair("60000.00", "150000.00")),
contract("Договор № 7 от 01.02.2026", pair(None, "20000.50"), pair("0.50", "30000.00")),
],
inn="7701234567",
)
second = Node(
"ООО Бета",
[contract("Договор подряда № 3 от 15.01.2026", pair("15000.00", None), pair("90000.00", "40000.00"))],
inn="7707654321",
)
third = Node(
"ООО Гамма",
[contract("Договор аренды № 2 от 09.01.2026", pair("120000.00", None), pair("30000.00", "100000.00"))],
inn="5001234567",
)
root = Node("60", [Node("60.01", [first, second]), Node("60.02", [third])])
spec = OsvSpec(
period="1 квартал 2026 г.",
data_type_line="Выводимые данные: Сумма",
variant=2,
inn_col=5,
first_sum_col=6,
)
return [build_osv(spec, root)]
def _s02() -> list[SheetSpec]:
spec = OsvSpec(
period="2 квартал 2025 г.", currency_line="Валюта БУ, НУ: руб.", hierarchy="indent", inn_col=5, first_sum_col=7
)
return [build_osv(spec, base_tree())]
def _s03() -> list[SheetSpec]:
spec = OsvSpec(period="I кв. 2025 г.", hierarchy="none", bold_accounts=True, inn_col=5, first_sum_col=7)
return [build_osv(spec, base_tree())]
def _s04() -> list[SheetSpec]:
first = alfa()
first.kids = [
leaf("USD", pair(None, "2500.00"), pair("1800.00", "4200.00"), indicator=True),
leaf("Кол.", pair("10", None), pair("5", "3"), indicator=True),
]
second = beta()
second.kids = [leaf("Вал.", pair("1000.00", None), pair(None, "400.00"), indicator=True)]
root = Node("60", [Node("60.01", [first, second]), Node("60.02", [gamma()])])
return [build_osv(OsvSpec(period="3 квартал 2025 г.", inn_col=5, first_sum_col=7), root)]
def _s05() -> list[SheetSpec]:
first = leaf(
"ООО Альфа",
pair("1234567.89", None),
pair("500.00", "-1234.00"),
inn="7701234567",
num_style="text",
)
second = beta(num_style="text_nbsp")
third = gamma(num_style="text_narrow")
root = Node("60", [Node("60.01", [first, second]), Node("60.02", [third, delta()])])
return [build_osv(OsvSpec(period="полугодие 2025 г.", inn_col=4, first_sum_col=5), root)]
def _s06() -> list[SheetSpec]:
first = build_osv(OsvSpec(account="60", inn_col=5, first_sum_col=6), base_tree(), "ОСВ 60")
second = build_osv(OsvSpec(account="62", inn_col=3, first_sum_col=4), receivables_tree(), "ОСВ 62")
return [first, reference_sheet(), second]
def _s07() -> list[SheetSpec]:
first = leaf("ООО Альфа", pair(None, None), pair("100000.00", "350000.50"), inn="7701234567")
second = leaf("ООО Бета", pair(None, None), pair("60000.00", "10000.00"), inn="7707654321")
third = leaf("ООО Гамма", pair(None, None), pair("20000.00", "20000.00"), inn="5001234567")
fourth = leaf("ООО Дельта", pair(None, None), pair("1000.00", "33000.40"), inn="7812345678")
root = Node("60", [Node("60.01", [first, second]), Node("60.02", [third, fourth])])
spec = OsvSpec(period="9 месяцев 2025 г.", opening=False, collapsed_closing=True, inn_col=5, first_sum_col=7)
return [build_osv(spec, root)]
def _s08() -> list[SheetSpec]:
spec = OsvSpec(period="период с 01.01.2025 по 31.03.2025", inn_col=5, kpp_col=6, first_sum_col=7)
return [build_osv(spec, base_tree(kpp=True))]
def _s09() -> list[SheetSpec]:
named = Node("60.01, Расчеты с поставщиками и подрядчиками", [alfa(), beta()])
advances = Node("60.02, Расчеты по авансам выданным", [gamma()])
letter = Node("60.АВ, Расчеты по авансам (буквенный субсчёт)", [delta()])
root = Node("60", [named, advances, letter])
return [build_osv(OsvSpec(period="4 квартал 2025 г.", inn_col=5, first_sum_col=7), root)]
def _s10() -> list[SheetSpec]:
tree = base_tree()
broken = tree.kids[0].kids[1]
assert broken.text == "ООО Бета"
broken.closing_debit_delta = D(1)
return [build_osv(OsvSpec(inn_col=5, first_sum_col=7), tree)]
def _s11() -> list[SheetSpec]:
spec = OsvSpec(period="12 месяцев 2025 г.", repeat_header_after=4, inn_col=5, first_sum_col=7)
return [build_osv(spec, base_tree())]
def _s12() -> list[SheetSpec]:
spec = OsvSpec(
period="январь - сентябрь 2025 г.",
data_type_line="Выводимые данные: НУ (данные налогового учета)",
inn_col=5,
first_sum_col=7,
)
return [statement_sheet(), build_osv(spec, base_tree(), "ОСВ 60")]
def _s13() -> list[SheetSpec]:
spec = OsvSpec(
period="2 квартал 2025 г.", currency_line="Валюта БУ, НУ: руб.", hierarchy="spaces", inn_col=5, first_sum_col=7
)
return [build_osv(spec, base_tree())]
# --- Запись файлов ---
_MODIFIED_RE = re.compile(rb"(<dcterms:modified[^>]*>)[^<]*(</dcterms:modified>)")
_CORE_PART = "docProps/core.xml"
_FIXED_W3CDTF = FIXED_TIME.strftime("%Y-%m-%dT%H:%M:%SZ").encode()
def _normalized_zip(data: bytes) -> bytes:
"""Тот же архив с постоянными метками времени: записей zip и `dcterms:modified` (openpyxl ставит текущее время
при каждом `save`, заданное в свойствах значение перезаписывает)."""
out = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(data)) as source, zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as target:
for info in source.infolist():
clone = zipfile.ZipInfo(info.filename, date_time=ZIP_DATE_TIME)
clone.compress_type = zipfile.ZIP_DEFLATED
clone.external_attr = info.external_attr
content = source.read(info.filename)
if info.filename == _CORE_PART:
content = _MODIFIED_RE.sub(rb"\g<1>" + _FIXED_W3CDTF + rb"\g<2>", content)
target.writestr(clone, content)
return out.getvalue()
def to_xlsx(sheets: Sequence[SheetSpec]) -> bytes:
book = openpyxl.Workbook()
default = book.active
assert default is not None
book.remove(default)
book.properties.creator = CREATOR
book.properties.lastModifiedBy = CREATOR
book.properties.created = FIXED_TIME
book.properties.modified = FIXED_TIME
for spec in sheets:
ws = book.create_sheet(spec.name)
for (row, col), cell in sorted(spec.cells.items()):
target = ws.cell(row, col, cell.value)
if cell.bold:
target.font = Font(bold=True)
if cell.indent:
target.alignment = Alignment(indent=cell.indent)
for top, left, bottom, right in spec.merges:
ws.merge_cells(start_row=top, start_column=left, end_row=bottom, end_column=right)
for row, level in sorted(spec.outline.items()):
ws.row_dimensions[row].outline_level = level
ws.column_dimensions["A"].width = 48
buffer = io.BytesIO()
book.save(buffer)
return _normalized_zip(buffer.getvalue())
def to_xlsb(sheets: Sequence[SheetSpec]) -> bytes:
"""xlsb без стилей и объединений: отступ уже выражен ведущими пробелами текста (`hierarchy="spaces"`)."""
return write_xlsb({spec.name: {key: cell.value for key, cell in spec.cells.items()} for spec in sheets})
FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSpec]], bytes]]] = {
"s01_variant2_header.xlsx": (_s01, to_xlsx),
"s02_no_outline_indent.xlsx": (_s02, to_xlsx),
"s03_no_outline_no_indent.xlsx": (_s03, to_xlsx),
"s04_currency_quantity_rows.xlsx": (_s04, to_xlsx),
"s05_text_numbers.xlsx": (_s05, to_xlsx),
"s06_two_osv_and_other_sheet.xlsx": (_s06, to_xlsx),
"s07_no_opening_collapsed_closing.xlsx": (_s07, to_xlsx),
"s08_extra_kpp_column.xlsx": (_s08, to_xlsx),
"s09_subaccount_names.xlsx": (_s09, to_xlsx),
"s10_broken_sum.xlsx": (_s10, to_xlsx),
"s11_header_repeat.xlsx": (_s11, to_xlsx),
"s12_statement_and_osv.xlsx": (_s12, to_xlsx),
"s13_no_outline_indent.xlsb": (_s13, to_xlsb),
}
FILE_NAMES = tuple(FILES)
def generate_all(directory: Path) -> list[Path]:
"""Записывает файлы набора в `directory` (создаётся); возвращает пути в порядке `FILE_NAMES`."""
directory.mkdir(parents=True, exist_ok=True)
written: list[Path] = []
for name, (build, serialize) in FILES.items():
path = directory / name
path.write_bytes(serialize(build()))
written.append(path)
return written
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="python -m tests.synthetic.generate",
description="Синтетические ОСВ s01…s13 (xlsx и один xlsb) в указанный каталог.",
)
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
args = parser.parse_args(argv)
for path in generate_all(args.directory):
print(path)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,72 @@
"""Генератор синтетики: детерминированность, состав набора и читаемость xlsb-писателя (не требует каталога fixtures)."""
import re
import time
from decimal import Decimal
from pathlib import Path
from src.parser.readers import read_book
from tests.synthetic.generate import FILE_NAMES, generate_all
from tests.synthetic.xlsb_writer import write_xlsb
# Сколько ждать между двумя генерациями: у записей zip разрешение метки времени — 2 с
ZIP_TIME_RESOLUTION_SECONDS = 2.1
LONG_TEXT_LENGTH = 400
BIG_ROW = 70000
def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None:
"""Повторный запуск (позже, чем разрешение меток времени) даёт те же байты для каждого файла набора."""
first = generate_all(tmp_path / "first")
time.sleep(ZIP_TIME_RESOLUTION_SECONDS)
second = generate_all(tmp_path / "second")
assert [path.name for path in first] == [path.name for path in second]
for left, right in zip(first, second, strict=True):
assert left.read_bytes() == right.read_bytes(), left.name
def test_generated_set_is_s01_to_s13_with_one_xlsb(tmp_path: Path) -> None:
names = [path.name for path in generate_all(tmp_path)]
assert names == list(FILE_NAMES)
assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names)
assert [name[1:3] for name in names] == [f"{number:02d}" for number in range(1, len(names) + 1)]
assert [name for name in names if name.endswith(".xlsb")] == ["s13_no_outline_indent.xlsb"]
assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names)
def test_xlsb_writer_round_trips_through_the_reader() -> None:
"""Тексты (с ведущими пробелами и кириллицей), числа и несколько листов читаются `src.parser.readers`."""
long_text = "щ" * LONG_TEXT_LENGTH # запись длиннее 127 байт: длина кодируется двумя байтами
data = write_xlsb(
{
"Лист_1": {
(1, 1): "Привет",
(2, 3): Decimal("1234567.89"),
(3, 1): " отступ",
(4, 2): long_text,
(BIG_ROW, 4): 7,
},
"Второй": {(1, 1): "Привет"},
}
)
sheets, warnings = read_book(data, "XLSB")
assert [sheet.name for sheet in sheets] == ["Лист_1", "Второй"]
first = sheets[0]
assert first.text_at(1, 1) == "Привет"
assert first.cell(2, 3).value == Decimal("1234567.89")
assert (first.text_at(3, 1), first.cell(3, 1).indent) == ("отступ", 4)
assert first.text_at(4, 2) == long_text
assert first.cell(BIG_ROW, 4).value == Decimal(7)
assert first.n_rows == BIG_ROW
assert sheets[1].text_at(1, 1) == "Привет"
assert warnings == ["xlsb: outline level недоступен"]
def test_xlsb_writer_is_deterministic() -> None:
cells = {"Лист_1": {(1, 1): "а", (2, 2): Decimal("1.50")}}
assert write_xlsb(cells) == write_xlsb(cells)

View File

@ -0,0 +1,414 @@
"""Сценарии спеки `osv-parsing` на синтетических файлах `../fixtures/osv/synthetic/` (Срез 2a, Task 11).
Ожидания выведены из сценариев спеки и из содержимого, которое генератор записывает в файл (суммы и тексты ниже
перечислены в тесте явно), а не из текущего вывода парсера: файл — вход, оракулом он не служит. Без каталога fixtures
тесты пропускаются (маркер `fixtures`).
"""
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from typing import cast
import pytest
from result_contract import assert_valid_result
from src.contract import FileType, OsvDocument, ResultStatus
from src.contract.result import OsvRow
from src.parser import ParseOutcome, get_parser
pytestmark = pytest.mark.fixtures
SYNTHETIC_DIR = Path("osv") / "synthetic"
EXPECTED_NAMES = [
"s01_variant2_header.xlsx",
"s02_no_outline_indent.xlsx",
"s03_no_outline_no_indent.xlsx",
"s04_currency_quantity_rows.xlsx",
"s05_text_numbers.xlsx",
"s06_two_osv_and_other_sheet.xlsx",
"s07_no_opening_collapsed_closing.xlsx",
"s08_extra_kpp_column.xlsx",
"s09_subaccount_names.xlsx",
"s10_broken_sum.xlsx",
"s11_header_repeat.xlsx",
"s12_statement_and_osv.xlsx",
"s13_no_outline_indent.xlsb",
]
XLSB_MISSING_REASON = "нет файла s13_no_outline_indent.xlsb (xlsb не получен; см. README fixtures)"
VARIANT_2 = 2
XLSB_WARNING = "xlsb: outline level недоступен"
ALFA = "ООО Альфа"
BETA = "ООО Бета"
GAMMA = "ООО Гамма"
D = Decimal
@dataclass(frozen=True)
class Expect:
"""Ожидания по файлу, общие для всех сценариев (шапка и итог разбора)."""
hierarchy: str
year: int
quarter: str
cumulative: bool
status: ResultStatus = ResultStatus.DONE
documents: int = 1
variant: int = 1
data_type: str = "BU"
EXPECTED: dict[str, Expect] = {
# «Период: 1 квартал 2026 г.» — вариант 2, данные «сумма»
"s01_variant2_header.xlsx": Expect("outline", 2026, "Q1", True, variant=2, data_type="SUM"),
"s02_no_outline_indent.xlsx": Expect("indent", 2025, "Q2", False),
# римский квартал «I кв.»
"s03_no_outline_no_indent.xlsx": Expect("account_pattern", 2025, "Q1", True),
"s04_currency_quantity_rows.xlsx": Expect("outline", 2025, "Q3", False),
# «полугодие»
"s05_text_numbers.xlsx": Expect("outline", 2025, "Q2", True),
"s06_two_osv_and_other_sheet.xlsx": Expect("outline", 2025, "Q1", True, documents=2),
# «9 месяцев»
"s07_no_opening_collapsed_closing.xlsx": Expect("outline", 2025, "Q3", True),
# диапазон дат «с 01.01.2025 по 31.03.2025»
"s08_extra_kpp_column.xlsx": Expect("outline", 2025, "Q1", True),
"s09_subaccount_names.xlsx": Expect("outline", 2025, "Q4", False),
"s10_broken_sum.xlsx": Expect("outline", 2025, "Q1", True, status=ResultStatus.NEEDS_CHECK),
# «12 месяцев»
"s11_header_repeat.xlsx": Expect("outline", 2025, "Q4", True),
# «январь - сентябрь», данные «НУ»
"s12_statement_and_osv.xlsx": Expect("outline", 2025, "Q3", True, data_type="NU"),
"s13_no_outline_indent.xlsb": Expect("indent", 2025, "Q2", False),
}
# --- Помощники ---
def synthetic_path(fixtures_dir: Path, name: str) -> Path:
return fixtures_dir / SYNTHETIC_DIR / name
def parse_path(path: Path) -> ParseOutcome:
return get_parser().parse(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper()))
def parse_named(fixtures_dir: Path, name: str) -> ParseOutcome:
path = synthetic_path(fixtures_dir, name)
if not path.is_file():
if name.endswith(".xlsb"):
pytest.skip(XLSB_MISSING_REASON)
pytest.fail(f"нет файла {name}: запустите генератор (см. README fixtures)")
return parse_path(path)
def documents(outcome: ParseOutcome) -> list[OsvDocument]:
assert outcome.error is None, outcome.error
docs = list(outcome.documents)
assert all(isinstance(doc, OsvDocument) for doc in docs)
return cast(list[OsvDocument], docs)
def one_document(outcome: ParseOutcome) -> OsvDocument:
(doc,) = documents(outcome)
return doc
def warnings_of(outcome: ParseOutcome) -> list[str]:
assert outcome.metrics is not None
assert outcome.metrics.warnings is not None
return outcome.metrics.warnings
def by_text(doc: OsvDocument) -> dict[str, OsvRow]:
"""Строки по тексту (в синтетике тексты строк уникальны внутри листа)."""
rows = {row.row_value: row for row in doc.rows}
assert len(rows) == len(doc.rows), "тексты строк синтетики должны быть уникальны"
return rows
def rows_text(doc: OsvDocument) -> list[str]:
return [row.row_value for row in doc.rows]
def sums(row: OsvRow) -> tuple[Decimal | None, ...]:
return (
row.opening_debit,
row.opening_credit,
row.turnover_debit,
row.turnover_credit,
row.closing_debit,
row.closing_credit,
)
def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | None, ...]]]:
"""Дерево документа: текст, уровень, родитель, суммы — для сравнения двух файлов одной ведомости."""
return [(row.row_value, row.level, row.parent_row_number, sums(row)) for row in doc.rows]
# --- Состав набора и общие ожидания ---
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s13 (s13 — xlsb; допустима деградация: нет только его)."""
present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file())
assert present == [name for name in EXPECTED_NAMES if name in present]
missing = [name for name in EXPECTED_NAMES if name not in present]
assert missing in ([], [EXPECTED_NAMES[-1]]), f"нет файлов набора: {missing}"
def test_every_synthetic_file_is_listed(synthetic_file: Path) -> None:
"""Каждый файл каталога описан в таблице ожиданий (новый файл без ожиданий — ошибка набора)."""
assert synthetic_file.name in EXPECTED, f"добавьте ожидания для {synthetic_file.name}"
def test_header_status_and_hierarchy(synthetic_file: Path) -> None:
"""Статус, число документов, стратегия иерархии и шапка из таблицы `EXPECTED`; результат валиден по контракту."""
expect = EXPECTED[synthetic_file.name]
outcome = parse_path(synthetic_file)
assert outcome.status is expect.status, outcome.metrics
docs = documents(outcome)
assert len(docs) == expect.documents
hierarchy_lines = [w for w in warnings_of(outcome) if f"hierarchy={expect.hierarchy}" in w]
assert len(hierarchy_lines) == expect.documents, warnings_of(outcome)
for doc in docs:
assert doc.header.header_variant == expect.variant
assert doc.header.period_year == expect.year
assert doc.header.period_quarter == expect.quarter
assert doc.header.is_cumulative is expect.cumulative
assert doc.header.data_type == expect.data_type
assert doc.header.organization_name == 'ООО "Тестовая организация"'
assert doc.rows[-1].group_type == "TOTAL"
if expect.status is ResultStatus.DONE:
assert doc.checks is not None
assert doc.checks.total_row_matches_root is True
assert doc.checks.balance_identity_violations == []
assert doc.checks.parent_sum_violations == []
assert_valid_result(outcome)
# --- Сценарии спеки ---
def test_s01_variant2_header_and_subkonto_groups(fixtures_dir: Path) -> None:
"""«Вариант 2»: `header_variant = 2`, `group_schema = [ACCOUNT, COUNTERPARTY, CONTRACT]`, `period_quarter = Q1`."""
doc = one_document(parse_named(fixtures_dir, "s01_variant2_header.xlsx"))
assert doc.header.header_variant == VARIANT_2
assert [group.type for group in doc.group_schema] == ["ACCOUNT", "COUNTERPARTY", "CONTRACT"]
assert doc.header.period_quarter == "Q1"
assert doc.header.period_text == "1 квартал 2026 г."
rows = by_text(doc)
contract = rows["Договор поставки № 15 от 10.01.2026"]
assert contract.group_type == "CONTRACT"
assert contract.parent_row_number == rows[ALFA].row_number
assert rows[ALFA].group_type == "COUNTERPARTY"
assert rows[ALFA].counterparty_inn == "7701234567"
def test_s02_indent_levels_and_tree(fixtures_dir: Path) -> None:
"""«Без outline level»: `hierarchy=indent`, уровни 0/1/2, родитель контрагента — субсчёт."""
doc = one_document(parse_named(fixtures_dir, "s02_no_outline_indent.xlsx"))
rows = by_text(doc)
assert [(text, rows[text].level) for text in ("60", "60.01", ALFA, "60.02", GAMMA, "Итого")] == [
("60", 0),
("60.01", 1),
(ALFA, 2),
("60.02", 1),
(GAMMA, 2),
("Итого", 0),
]
assert rows[ALFA].parent_row_number == rows["60.01"].row_number
assert rows["60.01"].parent_row_number == rows["60"].row_number
assert rows["60"].parent_row_number is None
def test_s02_and_s03_describe_the_same_tree(fixtures_dir: Path) -> None:
"""Одна ведомость с отступами (s02) и с кодами счетов и жирностью (s03): дерево и суммы совпадают."""
indent = one_document(parse_named(fixtures_dir, "s02_no_outline_indent.xlsx"))
pattern = one_document(parse_named(fixtures_dir, "s03_no_outline_no_indent.xlsx"))
assert tree(indent) == tree(pattern)
def test_s03_account_pattern_levels(fixtures_dir: Path) -> None:
"""«Без outline и отступов»: уровни по кодам счетов и жирности, контрольные суммы сходятся (в общем тесте)."""
outcome = parse_named(fixtures_dir, "s03_no_outline_no_indent.xlsx")
doc = one_document(outcome)
rows = by_text(doc)
assert [rows[text].level for text in ("60", "60.01", ALFA, BETA, "Иванов Иван", "60.02", GAMMA)] == [
0,
1,
2,
2,
2,
1,
2,
]
assert rows[GAMMA].parent_row_number == rows["60.02"].row_number
assert outcome.status is ResultStatus.DONE
def test_s04_currency_and_quantity_rows(fixtures_dir: Path) -> None:
"""«Строки Вал./Кол.»: USD и Вал. — CURRENCY, Кол. — QUANTITY; в проверку суммы детей не входят, статус `done`."""
outcome = parse_named(fixtures_dir, "s04_currency_quantity_rows.xlsx")
doc = one_document(outcome)
rows = by_text(doc)
assert rows["USD"].data_type == "CURRENCY"
assert rows["Кол."].data_type == "QUANTITY"
assert rows["Вал."].data_type == "CURRENCY"
assert rows["USD"].parent_row_number == rows[ALFA].row_number
assert rows["Вал."].parent_row_number == rows[BETA].row_number
assert rows[ALFA].data_type == "BU"
assert rows[ALFA].group_type == "COUNTERPARTY"
# суммы показателей остаются в шести полях
assert rows["Кол."].opening_debit == D("10.00")
assert rows["USD"].opening_credit == D("2500.00")
assert outcome.status is ResultStatus.DONE
def test_s05_text_numbers(fixtures_dir: Path) -> None:
"""«Текстовые числа»: «1 234 567,89» → 1234567.89, «(1 234,00)» → -1234.00, «—»/«–»/«-» → null."""
doc = one_document(parse_named(fixtures_dir, "s05_text_numbers.xlsx"))
rows = by_text(doc)
assert sums(rows[ALFA]) == (D("1234567.89"), None, D("500.00"), D("-1234.00"), D("1236301.89"), None)
# неразрывные пробелы и «–»
assert sums(rows[BETA]) == (D("15000.00"), None, D("90000.00"), D("40000.00"), D("65000.00"), None)
# узкие неразрывные пробелы и «-»
assert sums(rows[GAMMA]) == (D("120000.00"), None, D("30000.00"), D("100000.00"), D("50000.00"), None)
def test_s06_two_osv_sheets_and_other_sheet(fixtures_dir: Path) -> None:
"""«Два листа ОСВ и посторонний лист»: два документа в порядке листов, в warnings — пропуск листа «Справка»."""
outcome = parse_named(fixtures_dir, "s06_two_osv_and_other_sheet.xlsx")
docs = documents(outcome)
assert [doc.locator for doc in docs] == ["sheet:ОСВ 60", "sheet:ОСВ 62"]
assert [doc.header.account_code_raw for doc in docs] == ["60", "62"]
assert any("Справка" in warning and "skipped" in warning for warning in warnings_of(outcome))
assert not any("ОСВ 60" in w and "skipped" in w or "ОСВ 62" in w and "skipped" in w for w in warnings_of(outcome))
assert outcome.metrics is not None
assert outcome.metrics.rows == sum(1 for doc in docs for row in doc.rows if row.group_type != "TOTAL")
def test_s07_no_opening_and_collapsed_closing(fixtures_dir: Path) -> None:
"""«Без начала и со свёрнутым сальдо»: `opening_* = null`, отрицательное свёрнутое — в `closing_credit`, warning."""
outcome = parse_named(fixtures_dir, "s07_no_opening_collapsed_closing.xlsx")
doc = one_document(outcome)
for row in doc.rows:
assert row.opening_debit is None
assert row.opening_credit is None
rows = by_text(doc)
assert (rows[ALFA].closing_debit, rows[ALFA].closing_credit) == (None, D("250000.50"))
assert (rows[BETA].closing_debit, rows[BETA].closing_credit) == (D("50000.00"), None)
assert (rows[GAMMA].closing_debit, rows[GAMMA].closing_credit) == (None, None)
assert rows[ALFA].turnover_credit == D("350000.50")
assert any("свёрнутое сальдо" in warning for warning in warnings_of(outcome))
assert outcome.status is ResultStatus.DONE
def test_s08_kpp_column_goes_to_extra(fixtures_dir: Path) -> None:
"""«Неизвестная колонка внутри таблицы»: `extra["КПП"]` у контрагентов, суммы не сдвинуты, ИНН в своей колонке."""
doc = one_document(parse_named(fixtures_dir, "s08_extra_kpp_column.xlsx"))
rows = by_text(doc)
assert rows[ALFA].extra == {"КПП": "770101001"}
assert rows[BETA].extra == {"КПП": "770701001"}
assert rows["Иванов Иван"].extra is None
assert rows["60"].extra is None
assert rows[ALFA].counterparty_inn == "7701234567"
assert sums(rows[ALFA]) == (None, D("250000.50"), D("180000.25"), D("420000.00"), None, D("490000.25"))
assert doc.columns is not None
assert doc.columns["inn"] == "E"
assert (doc.columns["opening_debit"], doc.columns["closing_credit"]) == ("G", "L")
def test_s09_subaccount_names_in_account_rows(fixtures_dir: Path) -> None:
"""«Название субсчёта в строке счёта»: ACCOUNT, `account_code_raw` без названия, `row_value` — исходный текст."""
doc = one_document(parse_named(fixtures_dir, "s09_subaccount_names.xlsx"))
rows = by_text(doc)
named = rows["60.01, Расчеты с поставщиками и подрядчиками"]
assert (named.group_type, named.account_code_raw) == ("ACCOUNT", "60.01")
assert rows["60.02, Расчеты по авансам выданным"].account_code_raw == "60.02"
# буквенный субсчёт (R-16): счёт, а не контрагент; дети наследуют его код
letter = rows["60.АВ, Расчеты по авансам (буквенный субсчёт)"]
assert (letter.group_type, letter.account_code_raw) == ("ACCOUNT", "60.АВ")
assert rows["ООО Дельта"].account_code_raw == "60.АВ"
assert rows["ООО Дельта"].group_type == "COUNTERPARTY"
assert rows[ALFA].account_code_raw == "60.01"
def test_s10_broken_sum_needs_check(fixtures_dir: Path) -> None:
"""«Испорченная сумма»: `needs_check`, нарушение тождества — у контрагента, суммы родителя — у его субсчёта."""
outcome = parse_named(fixtures_dir, "s10_broken_sum.xlsx")
doc = one_document(outcome)
rows = by_text(doc)
assert outcome.status is ResultStatus.NEEDS_CHECK
assert doc.checks is not None
assert doc.checks.balance_identity_violations == [rows[BETA].row_number]
assert doc.checks.parent_sum_violations == [rows["60.01"].row_number]
assert doc.checks.total_row_matches_root is True
assert rows[BETA].closing_debit == D("65001.00")
texts = [w for w in warnings_of(outcome) if "§1.4" in w]
assert any(str(rows[BETA].row_number) in w for w in texts)
assert any(str(rows["60.01"].row_number) in w for w in texts)
def test_s11_header_repeat_is_skipped(fixtures_dir: Path) -> None:
"""«Повтор заголовка внутри данных» (печать по страницам): строки заголовка не данные, дерево не нарушено."""
outcome = parse_named(fixtures_dir, "s11_header_repeat.xlsx")
doc = one_document(outcome)
assert rows_text(doc) == [
"60",
"60.01",
ALFA,
BETA,
"Иванов Иван",
"60.02",
GAMMA,
"ООО Дельта",
"ИП Сидоров",
"Итого",
]
rows = by_text(doc)
assert [rows[text].level for text in rows_text(doc)] == [0, 1, 2, 2, 2, 1, 2, 2, 2, 0]
assert rows["60.02"].parent_row_number == rows["60"].row_number
assert outcome.status is ResultStatus.DONE
def test_s12_statement_sheet_is_skipped_with_warning(fixtures_dir: Path) -> None:
"""«Лист ББ + лист ОСВ»: один документ, лист формы пропущен с предупреждением о подсрезе 2b."""
outcome = parse_named(fixtures_dir, "s12_statement_and_osv.xlsx")
(doc,) = documents(outcome)
assert doc.locator == "sheet:ОСВ 60"
assert any("Бухгалтерский баланс" in w and "skipped: statement" in w for w in warnings_of(outcome))
assert outcome.status is ResultStatus.DONE
def test_s13_xlsb_equals_s02_except_locator_and_warnings(fixtures_dir: Path) -> None:
"""«XLSB»: результат совпадает с xlsx-оригиналом (s02), кроме `locator` и предупреждения об outline."""
xlsb = parse_named(fixtures_dir, "s13_no_outline_indent.xlsb")
xlsx = parse_named(fixtures_dir, "s02_no_outline_indent.xlsx")
xlsb_doc, xlsx_doc = one_document(xlsb), one_document(xlsx)
assert xlsb_doc.model_copy(update={"locator": ""}) == xlsx_doc.model_copy(update={"locator": ""})
assert xlsb.status is xlsx.status is ResultStatus.DONE
assert XLSB_WARNING in warnings_of(xlsb)
assert [w for w in warnings_of(xlsb) if w != XLSB_WARNING] == warnings_of(xlsx)
assert any("hierarchy=indent" in w for w in warnings_of(xlsb))
assert xlsb.metrics is not None
assert xlsx.metrics is not None
assert xlsb.metrics.rows == xlsx.metrics.rows

View File

@ -0,0 +1,191 @@
"""Минимальный писатель XLSB (BIFF12) для тестов: значения ячеек и имена листов, без стилей и объединений.
Это НЕ файл Excel: в книге нет `styles.bin`, свойств документа и прочего, что пишет Excel. Структура — ровно то, что
нужно читателю `pyxlsb` (его использует `src.parser.readers.xlsb`): `[Content_Types].xml`, `_rels/.rels`,
`xl/workbook.bin` (+ `.rels`), `xl/sharedStrings.bin`, `xl/worksheets/sheetN.bin`. Строки лежат в таблице общих
строк (`BrtCellIsst`), числа — `BrtCellReal` (double). Запись детерминирована: порядок частей фиксирован, метки времени
в zip постоянны.
Назначение — получить `s13_no_outline_indent.xlsb` без Excel/LibreOffice (ruling R-9); совместимость с Excel не
проверялась. Реальный xlsb, сохранённый Excel, остаётся нерешённым вопросом доверия к читателю (см. README fixtures).
"""
import io
import struct
import zipfile
from collections.abc import Mapping, Sequence
from decimal import Decimal
__all__ = ["write_xlsb"]
# Метка времени всех частей архива постоянна: файл байт в байт воспроизводим
ZIP_DATE_TIME = (2026, 1, 1, 0, 0, 0)
# Идентификаторы записей BIFF12 (MS-XLSB 2.3)
_ROW_HDR = 0x0000
_CELL_REAL = 0x0005
_CELL_ISST = 0x0007
_SST_ITEM = 0x0013
_BEGIN_BOOK = 0x0183
_END_BOOK = 0x0184
_BEGIN_SHEET = 0x0181
_END_SHEET = 0x0182
_BEGIN_BUNDLE_SHS = 0x018F
_END_BUNDLE_SHS = 0x0190
_BUNDLE_SH = 0x019C
_BEGIN_SHEET_DATA = 0x0191
_END_SHEET_DATA = 0x0192
_DIMENSION = 0x0194
_BEGIN_SST = 0x019F
_END_SST = 0x01A0
_ID_SINGLE_BYTE_LIMIT = 0x80
_LEN_CONTINUATION = 0x80
_LEN_PAYLOAD_MASK = 0x7F
_LEN_BITS = 7
_ROW_HEIGHT = 300 # 15 pt в двадцатых долях пункта
CellValue = str | int | Decimal | float
SheetCells = Mapping[tuple[int, int], CellValue] # (строка, колонка) 1-based -> значение
_CONTENT_TYPES = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
'<Default Extension="xml" ContentType="application/xml"/>'
'<Override PartName="/xl/workbook.bin" ContentType="application/vnd.ms-excel.sheet.binary.macroEnabled.main"/>'
"{sheets}"
'<Override PartName="/xl/sharedStrings.bin" '
'ContentType="application/vnd.ms-excel.sharedStrings"/>'
"</Types>"
)
_SHEET_OVERRIDE = '<Override PartName="/xl/worksheets/sheet{n}.bin" ContentType="application/vnd.ms-excel.worksheet"/>'
_ROOT_RELS = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
'<Relationship Id="rId1" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
'Target="xl/workbook.bin"/>'
"</Relationships>"
)
_WORKBOOK_RELS = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
"{sheets}"
'<Relationship Id="rId{strings}" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" '
'Target="sharedStrings.bin"/>'
"</Relationships>"
)
_SHEET_REL = (
'<Relationship Id="rId{n}" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
'Target="worksheets/sheet{n}.bin"/>'
)
def _record(record_id: int, payload: bytes = b"") -> bytes:
"""Запись BIFF12: идентификатор (1–2 байта), длина (7-битные группы), данные."""
head = bytes([record_id]) if record_id < _ID_SINGLE_BYTE_LIMIT else struct.pack("<H", record_id)
size = len(payload)
length = bytearray()
while True:
chunk = size & _LEN_PAYLOAD_MASK
size >>= _LEN_BITS
length.append(chunk | (_LEN_CONTINUATION if size else 0))
if not size:
break
return head + bytes(length) + payload
def _wide_string(text: str) -> bytes:
"""XLWideString: число символов (uint32) и UTF-16LE."""
return struct.pack("<I", len(text)) + text.encode("utf-16-le")
def _shared_strings(strings: Sequence[str], total: int) -> bytes:
body = [_record(_BEGIN_SST, struct.pack("<II", total, len(strings)))]
body += [_record(_SST_ITEM, b"\x00" + _wide_string(text)) for text in strings]
body.append(_record(_END_SST))
return b"".join(body)
def _workbook(names: Sequence[str]) -> bytes:
body = [_record(_BEGIN_BOOK), _record(_BEGIN_BUNDLE_SHS)]
for number, name in enumerate(names, start=1):
# hsState (видимый), iTabID, strRelID, strName
body.append(
_record(_BUNDLE_SH, struct.pack("<II", 0, number) + _wide_string(f"rId{number}") + _wide_string(name))
)
body += [_record(_END_BUNDLE_SHS), _record(_END_BOOK)]
return b"".join(body)
def _number(value: int | Decimal | float) -> float:
return float(value)
def _worksheet(cells: SheetCells, string_ids: dict[str, int]) -> bytes:
rows: dict[int, dict[int, CellValue]] = {}
for (row, col), value in cells.items():
rows.setdefault(row, {})[col] = value
last_row = max(rows, default=1)
last_col = max((col for row in rows.values() for col in row), default=1)
body = [
_record(_BEGIN_SHEET),
# 0-based границы: строки, колонки
_record(_DIMENSION, struct.pack("<IIII", 0, last_row - 1, 0, last_col - 1)),
_record(_BEGIN_SHEET_DATA),
]
for row in sorted(rows):
# rw, ixfe, miyRw, флаги, число диапазонов колонок
body.append(_record(_ROW_HDR, struct.pack("<IIHBBI", row - 1, 0, _ROW_HEIGHT, 0, 0, 0)))
for col in sorted(rows[row]):
value = rows[row][col]
head = struct.pack("<II", col - 1, 0) # колонка, стиль
if isinstance(value, str):
body.append(_record(_CELL_ISST, head + struct.pack("<I", string_ids[value])))
else:
body.append(_record(_CELL_REAL, head + struct.pack("<d", _number(value))))
body += [_record(_END_SHEET_DATA), _record(_END_SHEET)]
return b"".join(body)
def write_xlsb(sheets: Mapping[str, SheetCells]) -> bytes:
"""Книга XLSB: имя листа → ячейки `{(строка, колонка): значение}` (1-based; текст или число)."""
names = list(sheets)
strings: list[str] = []
string_ids: dict[str, int] = {}
total = 0
for cells in sheets.values():
for _, value in sorted(cells.items()):
if isinstance(value, str):
total += 1
if value not in string_ids:
string_ids[value] = len(strings)
strings.append(value)
parts: list[tuple[str, bytes]] = [
(
"[Content_Types].xml",
_CONTENT_TYPES.format(
sheets="".join(_SHEET_OVERRIDE.format(n=n) for n in range(1, len(names) + 1))
).encode(),
),
("_rels/.rels", _ROOT_RELS.encode()),
(
"xl/_rels/workbook.bin.rels",
_WORKBOOK_RELS.format(
sheets="".join(_SHEET_REL.format(n=n) for n in range(1, len(names) + 1)), strings=len(names) + 1
).encode(),
),
("xl/workbook.bin", _workbook(names)),
("xl/sharedStrings.bin", _shared_strings(strings, total)),
]
parts += [(f"xl/worksheets/sheet{n}.bin", _worksheet(sheets[name], string_ids)) for n, name in enumerate(names, 1)]
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for name, data in parts:
info = zipfile.ZipInfo(name, date_time=ZIP_DATE_TIME)
info.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(info, data)
return buffer.getvalue()

View File

@ -38,6 +38,7 @@ def test_synthetic_excluded_and_foreign_files_ignored(tmp_path: Path) -> None:
(osv / "notes.txt").write_bytes(b"")
(osv / "synthetic" / "s.xlsx").write_bytes(b"")
(osv / "synthetic" / ".gitkeep").write_bytes(b"")
(osv / "synthetic" / "README.md").write_bytes(b"")
assert osv_fixture_files(tmp_path) == [osv / "a.xls", osv / "c.xlsb", osv / "rb" / "b.xlsx"]
assert synthetic_files(tmp_path) == [osv / "synthetic" / "s.xlsx"]

View File

@ -397,9 +397,9 @@ def get_parser(settings: Settings | None = None) -> Parser
| `s12_statement_and_osv.xlsx` | лист ББ + лист ОСВ → один документ + warning |
| `s13_no_outline_indent.xlsb` | xlsb, сконвертирован вручную из `s02` (LibreOffice/Excel), закоммичен; `hierarchy=indent` |
- [ ] **Step 1:** тесты `test_synthetic_files.py` (параметр `synthetic_file`, маркер `fixtures`): таблица «файл → ожидания» (стратегия, статус, число документов, конкретные поля из сценариев спеки); для `s13` — результат равен `s02` кроме `locator`/warnings.
- [ ] **Step 2:** генератор; `python -m tests.synthetic.generate ../../fixtures/osv/synthetic` (из `api/`); проверить детерминированность (повторный запуск — файлы байт в байт). xlsb: конвертировать `s02` вручную; если инструмента на машине нет — записать в README fixtures и в ledger как `deferred` с инструкцией, тест `s13` помечается `skip` с причиной «нет файла» (без провала плана).
- [ ] **Step 3:** коммит в fixtures: `osv/synthetic: 12 синтетических ОСВ и xlsb (Срез 2a), README`; коммит здесь: `test: генератор синтетики и сценарии спеки на синтетических файлах`.
- [x] **Step 1:** тесты `test_synthetic_files.py` (параметр `synthetic_file`, маркер `fixtures`): таблица «файл → ожидания» (стратегия, статус, число документов, конкретные поля из сценариев спеки); для `s13` — результат равен `s02` кроме `locator`/warnings.
- [x] **Step 2:** генератор; `python -m tests.synthetic.generate ../../fixtures/osv/synthetic` (из `api/`); проверить детерминированность (повторный запуск — файлы байт в байт). xlsb: конвертировать `s02` вручную; если инструмента на машине нет — записать в README fixtures и в ledger как `deferred` с инструкцией, тест `s13` помечается `skip` с причиной «нет файла» (без провала плана).
- [x] **Step 3:** коммит в fixtures: `osv/synthetic: 12 синтетических ОСВ и xlsb (Срез 2a), README`; коммит здесь: `test: генератор синтетики и сценарии спеки на синтетических файлах`.
---

View File

@ -54,7 +54,7 @@
## 4. Синтетика, golden, линтер
- [ ] 4.1 Генератор синтетики `tests/synthetic/generate.py` (детерминированный openpyxl) → `../fixtures/osv/synthetic/`: вариант 2
- [x] 4.1 Генератор синтетики `tests/synthetic/generate.py` (детерминированный openpyxl) → `../fixtures/osv/synthetic/`: вариант 2
шапки; без outline (отступы); без outline и отступов; Вал./Кол.; текстовые числа и скобки; два листа ОСВ + посторонний;
без «начала» + свёрнутое сальдо; КПП-колонка; название субсчёта в строке; испорченная сумма; плюс один xlsb, сконвертированный
вручную из синтетического xlsx и закоммиченный с пометкой в README fixtures. Тесты сценариев спеки на этих файлах.