diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py
index c044f20..31e455d 100644
--- a/api/tests/fixtures_helpers.py
+++ b/api/tests/fixtures_helpers.py
@@ -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
+ )
diff --git a/api/tests/synthetic/__init__.py b/api/tests/synthetic/__init__.py
new file mode 100644
index 0000000..56637b7
--- /dev/null
+++ b/api/tests/synthetic/__init__.py
@@ -0,0 +1 @@
+"""Синтетические ОСВ для форматов, которых нет в реальных файлах: генератор и тесты сценариев спеки."""
diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py
new file mode 100644
index 0000000..4a02131
--- /dev/null
+++ b/api/tests/synthetic/generate.py
@@ -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"(]*>)[^<]*()")
+_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())
diff --git a/api/tests/synthetic/test_generate.py b/api/tests/synthetic/test_generate.py
new file mode 100644
index 0000000..a9a0cfc
--- /dev/null
+++ b/api/tests/synthetic/test_generate.py
@@ -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)
diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py
new file mode 100644
index 0000000..8ad6850
--- /dev/null
+++ b/api/tests/synthetic/test_synthetic_files.py
@@ -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
diff --git a/api/tests/synthetic/xlsb_writer.py b/api/tests/synthetic/xlsb_writer.py
new file mode 100644
index 0000000..7f4e4c5
--- /dev/null
+++ b/api/tests/synthetic/xlsb_writer.py
@@ -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 = (
+ '\n'
+ ''
+ ''
+ ''
+ ''
+ "{sheets}"
+ ''
+ ""
+)
+_SHEET_OVERRIDE = ''
+_ROOT_RELS = (
+ '\n'
+ ''
+ ''
+ ""
+)
+_WORKBOOK_RELS = (
+ '\n'
+ ''
+ "{sheets}"
+ ''
+ ""
+)
+_SHEET_REL = (
+ ''
+)
+
+
+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(">= _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(" bytes:
+ body = [_record(_BEGIN_SST, struct.pack(" 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(" 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(" 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()
diff --git a/api/tests/unit/test_fixtures_helpers.py b/api/tests/unit/test_fixtures_helpers.py
index 1614842..792777e 100644
--- a/api/tests/unit/test_fixtures_helpers.py
+++ b/api/tests/unit/test_fixtures_helpers.py
@@ -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"]
diff --git a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md
index 5e57e2b..c190b4d 100644
--- a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md
+++ b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md
@@ -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: генератор синтетики и сценарии спеки на синтетических файлах`.
---
diff --git a/openspec/changes/slice-2a-osv-parser/tasks.md b/openspec/changes/slice-2a-osv-parser/tasks.md
index e987753..09c3a0c 100644
--- a/openspec/changes/slice-2a-osv-parser/tasks.md
+++ b/openspec/changes/slice-2a-osv-parser/tasks.md
@@ -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. Тесты сценариев спеки на этих файлах.