"""Генератор синтетических ОСВ (Срез 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())