"""Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`. Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9); описание — `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", "ORG", "SheetSpec", "OsvSpec", "build_osv", "base_tree", "to_xlsx"] 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"] # Как ИНН контрагента записан в файле: отдельной колонкой, в тексте названия (разные разделители), отдельной строкой # над названием, склеенным «ИНН/КПП» в ячейке колонки ИНН InnStyle = Literal["column", "comma", "paren_label", "label", "space", "above", "kpp_cell"] # --- Дерево ведомости --- @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" inn_style: InnStyle = "column" 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 = "Контрагенты, Договоры" detail_label: str = "Детализация по субсчетам, субконто" # метка перед перечнем субконто (вариант 2) org_name: str | None = ORG # None — в шапке нет строки с названием организации group_titles: dict[str, str] | None = None # названия групп сумм вместо `GROUP_TITLES` inn_header: str = "ИНН" # подпись колонки ИНН («ИНН/КПП» — склеенная) labels_below: bool = False # вариант 1: «Счет» и «Контрагенты» — отдельными строками под подзаголовками сумм 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`: строка групп и строка Дебет/Кредит; возвращает номер следующей строки. `labels_below` (только для блока с меткой «Счет»): метки группировок и «ИНН» — отдельными строками под Дебет/Кредит (так печатает 1С), в колонках сумм у них пусто. """ titles = spec.group_titles or GROUP_TITLES below = spec.labels_below and bool(label) label_row = top + 2 if below else top # строка меток группировок (первая) и колонок ИНН/КПП sheet.put(label_row, 1, label) sheet.put(label_row + 1, 1, sub_label) service_row = label_row + 1 if below else top + 1 if spec.inn_col is not None: sheet.put(service_row, spec.inn_col, spec.inn_header) if spec.kpp_col is not None: sheet.put(service_row, spec.kpp_col, "КПП") for key, col, width in _groups(spec): sheet.put(top, col, 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 + 4 if below else top + 2 def _header_v1(sheet: SheetSpec, spec: OsvSpec) -> int: if spec.org_name is not None: sheet.put(1, 1, spec.org_name) 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: if spec.org_name is not None: sheet.put(1, 1, spec.org_name) sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {spec.account}") sheet.put(3, 1, f"Период: {spec.period}") sheet.put(4, 1, f"{spec.detail_label}: {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 _INN_IN_NAME = { "comma": "{name}, {inn}", "paren_label": "{name} (ИНН {inn})", "label": "{name} ИНН: {inn}", "space": "{name} {inn}", } def _name_with_inn(node: Node) -> str: """Название строки; ИНН дописан в текст, если так задан `inn_style` (иначе — только название).""" template = _INN_IN_NAME.get(node.inn_style) return node.text if template is None or not node.inn else template.format(name=node.text, inn=node.inn) def _row_cells( # noqa: PLR0913 - строка листа описывается полностью одним вызовом sheet: SheetSpec, spec: OsvSpec, row: int, entry: tuple[Node, int], six: Six, *, blank: bool = False ) -> None: """Одна строка данных (`entry` — узел и его глубина): текст с оформлением иерархии, ИНН/КПП и суммы.""" node, depth = entry mode = spec.hierarchy shown = _name_with_inn(node) text = INDENT_STEP * depth + shown if mode == "spaces" else shown 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 and node.inn_style in ("column", "kpp_cell"): sheet.put(row, spec.inn_col, f"{node.inn}/{node.kpp}" if node.inn_style == "kpp_cell" else node.inn) if spec.kpp_col is not None and node.kpp: sheet.put(row, spec.kpp_col, node.kpp) if blank: return # строка без сумм (ИНН над названием) 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, "Счет", "Контрагенты") if node.inn_style == "above" and node.inn: # ИНН отдельной строкой над названием, без сумм, на том же уровне оформления inn_line = Node(node.inn) _row_cells(sheet, spec, next_row, (inn_line, depth), (ZERO,) * 6, blank=True) 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 form_sheet(name: str, *lines: str) -> SheetSpec: """Лист формы без таблицы: в шапке только `lines` (так проверяется каждый признак формы по отдельности).""" sheet = SheetSpec(name) for row, line in enumerate(lines, start=1): sheet.put(row, 1, line) sheet.put(len(lines) + 2, 1, "Показатель") sheet.put(len(lines) + 2, 2, "Код") 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]: # Смешанные знаки у детей (кредитовое у Альфы, дебетовое у Беты) намеренно проверяют правило R-21: свёрнутое сальдо # родителя сверяется по нетто, а не по сумме модулей 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())] def _s14() -> list[SheetSpec]: """Три листа форм, опознаваемые каждый одним своим признаком (покрытие `statement_markers`), и лист ОСВ.""" return [ form_sheet("Форма 1", "Форма по ОКУД 0710001"), form_sheet("ОФР", "Отчет о финансовых результатах"), form_sheet("Форма 2", "Форма по ОКУД 0710002"), build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree(), "ОСВ 60"), ] def _s15() -> list[SheetSpec]: """Названия групп сумм: «Входящее/Конечное сальдо» на первом листе, «Начальное/Исходящее сальдо» на втором.""" first = OsvSpec( account="60", inn_col=5, first_sum_col=7, group_titles={"opening": "Входящее сальдо", "turnover": "Обороты", "closing": "Конечное сальдо"}, ) second = OsvSpec( account="62", inn_col=3, first_sum_col=4, group_titles={"opening": "Начальное сальдо", "turnover": "Обороты", "closing": "Исходящее сальдо"}, ) return [build_osv(first, base_tree(), "ОСВ 60"), build_osv(second, receivables_tree(), "ОСВ 62")] def _s16() -> list[SheetSpec]: """Шапка без названия организации; период — только год; валюта с пояснением; метки группировок отдельными строками («Счет» среди них); перечень «Детализация: …» без слова «субконто» и группировки вне ключевых.""" first = OsvSpec( account="60", period="2024", currency_line="Валюта БУ, НУ: RUB (код 643)", org_name=None, labels_below=True, inn_col=5, first_sum_col=7, ) second = OsvSpec( account="62", variant=2, period="2024", org_name=None, detail_label="Детализация по субсчетам", detail="Номенклатура, Статьи затрат, Нематериальные активы, Валюта", inn_col=None, first_sum_col=3, ) return [build_osv(first, base_tree(), "ОСВ 60"), build_osv(second, receivables_tree(), "ОСВ 62")] # --- Запись файлов --- _MODIFIED_RE = re.compile(rb"(]*>)[^<]*()") _CORE_PART = "docProps/core.xml" _FIXED_W3CDTF = FIXED_TIME.strftime("%Y-%m-%dT%H:%M:%SZ").encode() def _s17() -> list[SheetSpec]: """ИНН в тексте названия контрагента (OQ-100): запятая, «(ИНН …)», «ИНН: …», пробел; колонки ИНН нет.""" first = alfa(inn_style="comma") second = beta(inn_style="paren_label") third = gamma(inn_style="label") fourth = sidorov() fourth.inn_style = "space" root = Node("60", [Node("60.01", [first, second, ivanov()]), Node("60.02", [third, fourth])]) return [build_osv(OsvSpec(period="1 квартал 2025 г.", inn_col=None, first_sum_col=5), root)] def _s18() -> list[SheetSpec]: """ИНН отдельной строкой над названием контрагента (OQ-130): строка без сумм, того же уровня; колонки ИНН нет.""" root = Node( "60", [Node("60.01", [alfa(inn_style="above"), beta(), ivanov()]), Node("60.02", [gamma(inn_style="above")])] ) return [build_osv(OsvSpec(period="2 квартал 2025 г.", inn_col=None, first_sum_col=5), root)] def _s19() -> list[SheetSpec]: """Колонка «ИНН/КПП» со склеенным значением «7701234567/770101001» в одной ячейке (OQ-100).""" items = [alfa(inn_style="kpp_cell"), beta(inn_style="kpp_cell"), ivanov()] for node in items: node.kpp = KPP.get(node.text) root = Node("60", [Node("60.01", items), Node("60.02", [gamma(), delta()])]) spec = OsvSpec(period="3 квартал 2025 г.", inn_col=5, inn_header="ИНН/КПП", first_sum_col=6) return [build_osv(spec, root)] 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), "s14_statement_forms.xlsx": (_s14, to_xlsx), "s15_group_title_synonyms.xlsx": (_s15, to_xlsx), "s16_header_synonyms.xlsx": (_s16, to_xlsx), "s17_inn_in_name.xlsx": (_s17, to_xlsx), "s18_inn_row_above.xlsx": (_s18, to_xlsx), "s19_inn_kpp_cell.xlsx": (_s19, to_xlsx), } 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…s19 (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())