"""Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`. Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во», строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках, s46 — выгрузка «по контрагентам» с блоками субсчетов (#71), s47 — иерархия по отступам: подуровень счёта, строка закрытия, вложенность по суммам (#78); s48…s57 — ОСВ других учётных систем (#81): позитивы s48…s54 (названия, группы и стороны, Navision без названия, xls и xlsb), негативы s55…s57 (почти-ОСВ, которые обязаны остаться прочими листами или формой); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_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.xls_writer import write_xls 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` прибавляется, родитель не меняется blank: bool = False # строка без сумм (строка ИНН без сумм, #35) twin: "Node | None" = None # строка ИНН-сосед (#35): суммы печатаются как у `twin`, в сумму родителя не входит def children(self) -> list["Node"]: return [kid for kid in self.kids if not kid.indicator and kid.twin is None] 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: «Счет» и «Контрагенты» — отдельными строками под подзаголовками сумм # Название ведомости вместо «Оборотно-сальдовая ведомость по счету … за …» (вариант 1) и его колонка (#81) title: str | None = None title_col: int = 1 untitled: bool = False # нет организации, названия и параметров: заголовок таблицы с `1 + top_padding` (#81) top_padding: int = 0 # Группы, подписанные двумя ячейками со стороной («НачалоДт» | «НачалоКт») вместо названия над «Дебет/Кредит»; # разделены все группы — строки подзаголовков нет (#81) split_groups: dict[str, tuple[str, str]] = field(default_factory=dict) total_text: str | None = None # подпись итоговой строки; None — «Итого» 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) groups = _groups(spec) one_row = all(key in spec.split_groups for key, _, _ in groups) # заголовок в одну строку, без «Дебет/Кредит» label_row = top + 2 if below else top # строка меток группировок (первая) и колонок ИНН/КПП sheet.put(label_row, 1, label) if not one_row: sheet.put(label_row + 1, 1, sub_label) service_row = label_row + 1 if below else (top if one_row 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: if key in spec.split_groups: debit, credit = spec.split_groups[key] sheet.put(top, col, debit) sheet.put(top, col + 1, credit) continue 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) if below: return top + 4 return top + 1 if one_row else top + 2 def _header_v1(sheet: SheetSpec, spec: OsvSpec) -> int: if spec.untitled: return _header_rows(sheet, spec, 1 + spec.top_padding, "Счет", "Контрагенты") if spec.org_name is not None: sheet.put(1, 1, spec.org_name) title = spec.title or f"Оборотно-сальдовая ведомость по счету {spec.account} за {spec.period}" sheet.put(2, spec.title_col, title) 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.twin or node).six(), blank=node.blank) next_row += 1 total = SheetSpec("") _row_cells(total, spec, 1, (Node(spec.total_text or 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 table_sheet(name: str, rows: Sequence[Sequence[str | int | D | None]], *, start_row: int = 1) -> SheetSpec: """Лист «как есть»: строки ячеек с `start_row`, `None` — пустая ячейка (листы-негативы #81).""" sheet = SheetSpec(name) for row, values in enumerate(rows, start=start_row): for col, value in enumerate(values, start=1): if value is not None: sheet.put(row, col, value) 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 MERGED = "<объединено>" # ячейка текста — продолжение объединения строки выше INDICATOR_TITLE = "Показа-\nтели" @dataclass(frozen=True) class GridRow: """Строка листа «по сетке»: текст (None — пусто, MERGED — объединение с ячейкой выше), метка колонки «Показатели», шесть сумм (начало Д/К, обороты Д/К, конец Д/К; строки — `Decimal` в файле) и outline level.""" text: str | None sums: tuple[str | None, str | None, str | None, str | None, str | None, str | None] label: str | None = None outline: int = 0 def _grid_osv( # noqa: PLR0913 - лист описывается полностью одним вызовом *, account: str, group_label: str, rows: Sequence[GridRow], data_type_line: str | None, indicator_column: bool, name: str = "Лист_1", ) -> SheetSpec: """ОСВ, заданная строками напрямую (не деревом `Node`): колонка «Показатели», объединённые названия, соседи.""" sheet = SheetSpec(name) sheet.put(1, 1, ORG) sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {account}") sheet.put(3, 1, "Период: 3 квартал 2025 г.") if data_type_line is not None: sheet.put(4, 1, data_type_line) sheet.put(5, 1, "Параметры:") sheet.put(5, 3, "Валюта БУ, НУ: Руб") label_col, first_sum = 4, 5 top = 7 sheet.put(top, 1, "Счет") if indicator_column: sheet.put(top, label_col, INDICATOR_TITLE) sheet.merge(top, label_col, top + 1, label_col) for index, key in enumerate(("opening", "turnover", "closing")): col = first_sum + 2 * index sheet.put(top, col, GROUP_TITLES[key]) sheet.merge(top, col, top, col + 1) sheet.put(top + 1, col, DEBIT) sheet.put(top + 1, col + 1, CREDIT) sheet.put(top + 1, 1, group_label) for row, item in enumerate(rows, start=top + 2): if item.text == MERGED: sheet.merge(row - 1, 1, row, 1) elif item.text: sheet.put(row, 1, item.text) if item.label is not None: sheet.put(row, label_col, item.label) if item.outline: sheet.outline[row] = item.outline for offset, value in enumerate(item.sums): if value is not None: sheet.put(row, first_sum + offset, D(value)) 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]: """Три листа форм, опознаваемые каждый одним своим признаком (заголовки и ОКУД словаря форм), и лист ОСВ.""" 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")] def _s33() -> list[SheetSpec]: """Колонка «Показатели» БУ/НУ (AURORA-1613): счёт 02, шапка без «Выводимые данные», название объединено на строки БУ и НУ, «Итого» по БУ и по НУ. Строки НУ — показатели строки БУ над ними.""" rows = [ GridRow("02", (None, "300000.00", "20000.00", "30000.00", None, "310000.00"), "БУ"), GridRow(None, (None, "300000.02", "20000.00", "30000.03", None, "310000.05"), "НУ"), GridRow("Печь подовая", (None, "100000.00", "20000.00", "10000.00", None, "90000.00"), "БУ", 1), GridRow(MERGED, (None, "100000.02", "20000.00", "10000.01", None, "90000.03"), "НУ", 1), GridRow("Плита индукционная", (None, "200000.00", None, "20000.00", None, "220000.00"), "БУ", 1), GridRow(None, (None, "200000.00", None, "20000.02", None, "220000.02"), "НУ", 1), GridRow("Итого", (None, "300000.00", "20000.00", "30000.00", None, "310000.00"), "БУ"), GridRow(MERGED, (None, "300000.02", "20000.00", "30000.03", None, "310000.05"), "НУ"), ] return [ _grid_osv(account="02", group_label="Основные средства", rows=rows, data_type_line=None, indicator_column=True) ] def _s34() -> list[SheetSpec]: """Колонка «Показатели»: «Вал.», «Кол.», «ПР», «ВР» и неизвестная метка «Ед.» — показатели вне сверки родителя.""" rows = [ GridRow("60", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00"), "БУ"), GridRow("ООО Альфа", ("1000.00", None, "500.00", "300.00", "1200.00", None), "БУ", 1), GridRow(None, ("12.50", None, "6.00", "3.00", "15.50", None), "Вал.", 1), GridRow(None, ("3", None, None, None, "3", None), "Кол.", 1), GridRow("ООО Бета", (None, "400.00", "100.00", None, None, "300.00"), "БУ", 1), GridRow(None, (None, None, "10.00", None, "10.00", None), "ПР", 1), GridRow(None, (None, "5.00", None, None, None, "5.00"), "ВР", 1), GridRow(None, ("1", None, None, None, "1", None), "Ед.", 1), GridRow("Итого", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00"), "БУ"), ] line = "Выводимые данные: БУ (данные бухгалтерского учета)" return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=line, indicator_column=True)] def _s35() -> list[SheetSpec]: """«Валюта <код>» — строка в рублях, «В валюте» — показатель (AURORA-1661): пары на одном outline-уровне.""" rows = [ GridRow("60", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00")), GridRow("60.21", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00"), outline=1), GridRow("ООО Альфа", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00"), outline=2), GridRow("Валюта USD", ("9000.00", None, "1000.00", None, "10000.00", None), outline=3), GridRow("В валюте", ("100.00", None, "11.00", None, "111.00", None), outline=3), GridRow("Валюта EUR", (None, "2000.00", None, "500.00", None, "2500.00"), outline=3), GridRow("В валюте", (None, "20.00", None, "5.00", None, "25.00"), outline=3), GridRow("Итого", ("9000.00", "2000.00", "1000.00", "500.00", "10000.00", "2500.00")), ] line = "Выводимые данные: БУ (данные бухгалтерского учета)" return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=line, indicator_column=False)] def _s36() -> list[SheetSpec]: """Шапка без «Выводимые данные», колонки «Показатели» нет: строки — BU по умолчанию с предупреждением.""" rows = [ GridRow("60", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00")), GridRow("ООО Альфа", ("1000.00", None, "500.00", "300.00", "1200.00", None), outline=1), GridRow("ООО Бета", (None, "400.00", "100.00", None, None, "300.00"), outline=1), GridRow("Итого", ("1000.00", "400.00", "600.00", "300.00", "1200.00", "300.00")), ] return [_grid_osv(account="60", group_label="Контрагенты", rows=rows, data_type_line=None, indicator_column=False)] # --- Запись файлов --- _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): строка без сумм, того же уровня; колонки ИНН нет. ИНН с верной контрольной суммой: без подсказки в шапке другой не склеивается (#35).""" first = alfa(inn_style="above") first.inn = "7701234560" third = gamma(inn_style="above") third.inn = "5001234561" root = Node("60", [Node("60.01", [first, beta(), ivanov()]), Node("60.02", [third])]) 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)] InnLayout = Literal["above_sibling", "above_parent", "below_child", "below_sibling"] def inn_rows_tree(node: Node, layout: InnLayout, *, sums: bool) -> Node: """То же дерево, где ИНН каждого контрагента выведен отдельной строкой в раскладке `layout` (#35): над / под наименованием, в дереве (родитель / ребёнок) или соседом; `sums` — у строки ИНН те же суммы, иначе строка пустая. Колонки ИНН нет, в названии ИНН не пишется.""" kids: list[Node] = [] for kid in node.kids: kid = inn_rows_tree(kid, layout, sums=sums) # noqa: PLW2901 - узел заменяется своей копией со строками ИНН if kid.inn is None or kid.indicator: kids.append(kid) continue inn, kid.inn = kid.inn, None if layout == "above_sibling": kids += [Node(inn, twin=kid, blank=not sums), kid] elif layout == "below_sibling": kids += [kid, Node(inn, twin=kid, blank=not sums)] elif layout == "above_parent": kids.append(Node(inn, [kid], blank=not sums)) else: kid.kids = [Node(inn, kid.kids, opening=kid.opening, turnover=kid.turnover, blank=not sums)] kids.append(kid) node.kids = kids return node def _inn_layout_tree() -> Node: """Счёт 60 для раскладок строк ИНН (#35): ИНН с верной контрольной суммой (10 и 12 цифр), физлицо без ИНН.""" first = alfa() first.inn = "7701234560" third = gamma() third.inn = "5001234561" return Node("60", [Node("60.01", [first, beta(), ivanov()]), Node("60.02", [third, sidorov()])]) def _s29() -> list[SheetSpec]: """xls BIFF5 без записи CODEPAGE, строки в cp1251 (AURORA-1662): отступ — пробелами, outline в xls-писателе нет.""" return [build_osv(OsvSpec(hierarchy="spaces", inn_col=5, first_sum_col=7), base_tree())] def _s30() -> list[SheetSpec]: """Счёт 10: материал «Вал» (без точки) — обычная номенклатура, не валютная строка; «Кол-во» — строка количества (AURORA-1663). «Вал.» с точкой и код валюты остаются метками (s04).""" shaft = leaf("Вал", pair("1500.00", None), pair("300.00", "100.00")) shaft.kids = [leaf("Кол-во", pair("10", None), pair("2", "1"), indicator=True)] bolt = leaf("Болт М8", pair("200.00", None), pair("50.00", None)) bolt.kids = [leaf("Кол-во", pair("400", None), pair("100", None), indicator=True)] root = Node("10", [Node("10.1", [shaft, bolt])]) spec = OsvSpec( account="10", variant=2, period="2 квартал 2025 г.", detail="Номенклатура", detail_label="Детализация по субсчетам, субконто", inn_col=None, first_sum_col=3, ) return [build_osv(spec, root)] def _s31() -> list[SheetSpec]: """Рабочий файл аналитика (AURORA-1664): под «Итого» дописаны подпись «Ответственный:» и блок с числами, в том числе строка, начинающаяся с «Итого»; таблица кончается на первой «Итого», хвост вне документа.""" sheet = build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree()) last = max(row for row, _ in sheet.cells) sheet.put(last + 2, 1, "Ответственный:") sheet.put(last + 2, 3, "Иванова И. И.") sheet.put(last + 4, 1, "ООО Альфа (ГК)") sheet.put(last + 4, 7, D("420000.00")) sheet.put(last + 4, 8, D("250000.50")) sheet.put(last + 5, 1, "Итого по ГК") sheet.put(last + 5, 7, D("420000.00")) sheet.put(last + 5, 8, D("250000.50")) return [sheet] def _s32() -> list[SheetSpec]: """Итоговая строка с подписью «Итого по счету 60» вместо точного «Итого» (I-1): правило `total_row` (начало текста) действует, когда точного «Итого» на листе нет.""" sheet = build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree()) last = max(row for row, _ in sheet.cells) sheet.cells[(last, 1)] = CellSpec("Итого по счету 60", bold=sheet.cells[(last, 1)].bold) return [sheet] FlatRow = tuple[str, tuple[str | None, ...], int] def _flat_sheet(texts: dict[int, str], group_row: int, rows: Sequence[FlatRow]) -> SheetSpec: """Плоская форма 1С (#59): колонка A — текст, B…G — суммы «начало / обороты / конец» × «Дебет | Кредит»; заголовок «Субконто» + группы (объединения B:C, D:E, F:G) в `group_row`, подзаголовки под ним, данные сразу ниже. `texts` — строки шапки над таблицей; в `rows` пустая сумма (None) — нет ячейки, отступ — выравнивание ячейки.""" sheet = SheetSpec(SHEET) for row, text in texts.items(): sheet.put(row, 1, text) sheet.put(group_row, 1, "Субконто") for index, key in enumerate(("opening", "turnover", "closing")): col = 2 + index * PAIR_WIDTH sheet.put(group_row, col, GROUP_TITLES[key]) sheet.merge(group_row, col, group_row, col + 1) sheet.put(group_row + 1, col, DEBIT) sheet.put(group_row + 1, col + 1, CREDIT) for offset, (text, sums, indent) in enumerate(rows): row = group_row + 2 + offset sheet.put(row, 1, text, indent=indent) for col, value in enumerate(sums, start=2): if value is not None: sheet.put(row, col, D(value)) return sheet def _s40() -> list[SheetSpec]: """Плоская форма (#59): счёт в заголовке «по счету: 76.Н.1» и вид аналитики «Контрагенты» строкой ниже, данные без outline и отступов; «Итого развернутое» — сумма строк, «Итого» — свёрнутое сальдо.""" texts = { 2: "Оборотно-сальдовая ведомость по счету: 76.Н.1", 3: "Контрагенты", 4: "за 2025 г.", 5: 'ООО "Товарное хозяйство"', 7: "Единица измерения: рубль (код по ОКЕИ 383)", } rows: list[FlatRow] = [ ("Август - Агро ООО", ("1000.00", None, "500.00", "300.00", "1200.00", None), 0), ("АВИС ООО", (None, "200.00", "100.00", "400.00", None, "500.00"), 0), ("Агроаспект ООО", (None, None, "50.00", "50.00", None, None), 0), ("Итого развернутое", ("1000.00", "200.00", None, None, "1200.00", "500.00"), 0), ("Итого", ("800.00", None, "650.00", "750.00", "700.00", None), 0), ] return [_flat_sheet(texts, 9, rows)] def _s41() -> list[SheetSpec]: """Плоская форма (#59): вид аналитики «Организации», период «за 2 Квартал 2026 г.» со словами «План счетов …».""" texts = { 2: "Оборотно-сальдовая ведомость по счету: 60", 3: "Организации", 4: "за 2 Квартал 2026 г. План счетов ООО Хреновская Нива", } rows: list[FlatRow] = [ ("Автодор-Платные Дороги ООО", ("820.21", None, "5000.00", "4991.00", "829.21", None), 0), ("АГРОПРОФИ ООО", (None, "2763.79", None, None, None, "2763.79"), 0), ("Итого развернутое", ("820.21", "2763.79", None, None, "829.21", "2763.79"), 0), ("Итого", (None, "1943.58", "5000.00", "4991.00", None, "1934.58"), 0), ] return [_flat_sheet(texts, 5, rows)] def _s42() -> list[SheetSpec]: """Плоская форма (#59): под счётом 76 идут субсчета «76.2», «76.5», «76.Н», «76.Н.1»; «Итого развернутое» перед «Итого».""" texts = { 2: "Оборотно-сальдовая ведомость по счету: 76", 3: "за 2025 г.", 4: 'ООО "Товарное хозяйство"', 6: "Единица измерения: рубль (код по ОКЕИ 383)", } rows: list[FlatRow] = [ ("76.2", ("1000.00", None, None, "1000.00", None, None), 0), ("76.5", (None, "300.00", "700.00", "600.00", None, "200.00"), 0), ("76.Н", (None, None, "5000.00", "5000.00", None, None), 0), ("76.Н.1", (None, None, "5000.00", "5000.00", None, None), 0), ("Итого развернутое", ("1000.00", "300.00", None, None, None, "200.00"), 0), ("Итого", ("700.00", None, "5700.00", "6600.00", None, "200.00"), 0), ] return [_flat_sheet(texts, 8, rows)] def _s43_s44(account: str, root: str, kids: tuple[str, str]) -> list[SheetSpec]: """Счёт с отступами: корень-субсчёт (без отступа) и два элемента аналитики с отступом 1, без вида аналитики в шапке; «Итого» перед «Итого развернутое».""" texts = { 1: 'ООО "Октябрьское"', 2: f"Оборотно-сальдовая ведомость по счету {account}", 3: "Период: 9 месяцев 2024 г.", } rows: list[FlatRow] = [ (root, ("34618000.00", None, "4236500.00", "38854500.00", None, None), 0), (kids[0], ("34618000.00", None, None, "34618000.00", None, None), 1), (kids[1], (None, None, "4236500.00", "4236500.00", None, None), 1), ("Итого", ("34618000.00", None, "4236500.00", "38854500.00", None, None), 0), ("Итого развернутое", ("34618000.00", None, "4236500.00", "38854500.00", None, None), 0), ] return [_flat_sheet(texts, 5, rows)] def _s43() -> list[SheetSpec]: """Плоская форма (#59): счёт 60, контрагенты без вида аналитики в шапке (подстроки с отступом).""" return _s43_s44("60", "60.01", ("ИП Иванов А.А.", 'ООО "ЭСТЕТ ОМСК"')) def _s44() -> list[SheetSpec]: """То же, что s43, но счёт 10: номенклатура вместо контрагентов.""" return _s43_s44("10", "10.01", ("Мука пшеничная", "Сахар-песок")) def _put_by_counterparty_head(sheet: SheetSpec) -> None: """Первые строки выгрузки 1С «по контрагентам»: организация, название, «За период | диапазон» (s45, s46).""" sheet.put(1, 1, 'ООО "Тестовая организация"') sheet.put(1, 2, "Оборотно-сальдовая ведомость") sheet.put(2, 1, "15 июля 2025(18:40)") sheet.put(2, 2, "За период") sheet.put(2, 3, "01.01.25..30.06.25") def _s45() -> list[SheetSpec]: """Выгрузка 1С «ОСВ по счёту 60 по контрагентам» (#71, AURORA-1714): счёт в шапке — «Счет Поставщика» | «60*» в соседних ячейках («Счет Клиента» без значения), без слов «по счету»; в первой колонке голые числовые коды контрагентов.""" rows: list[FlatRow] = [ ("441", (None, "108000.00", "108000.00", None, None, None), 0), ("431", (None, "200.00", "200.00", None, None, None), 0), ("199", (None, "50.00", "50.00", None, None, None), 0), ("Итого", (None, "108250.00", "108250.00", None, None, None), 0), ] sheet = _flat_sheet({}, 6, rows) _put_by_counterparty_head(sheet) sheet.put(3, 2, "Счет Поставщика") sheet.put(3, 3, "60*") sheet.put(4, 2, "Счет Клиента") return [sheet] def _s46() -> list[SheetSpec]: """Выгрузка 1С «ОСВ по счёту 60 по контрагентам» целиком (#71, AURORA-1714): «За период | 01.01.25..30.06.25», «Счет Поставщика | 60*», вид субконто «Поставщики» в колонке суммы первой строки данных; блоки субсчетов 60.111 и 60.112 — код контрагента с суммами, строка названия без сумм, договоры (в том числе «441»), «ИТОГ по <субсчёт>», в конце «ИТОГ».""" table: list[tuple[str, tuple[str | None, ...]]] = [ ("60.111", (None,) * 6), ("П005482", (None, "100.00", "100.00", None, None, None)), ("ВЕСТ БИР ООО", (None,) * 6), ("ДП87825;З-139/21-1", (None, "100.00", "100.00", None, None, None)), ("П002913", (None, "50.00", "70.00", "20.00", None, None)), ("М ДИСТРИБЬЮШН", (None,) * 6), ("ДП60469;З-46/20", (None, "20.00", "30.00", "10.00", None, None)), ("441", (None, "30.00", "40.00", "10.00", None, None)), ("ИТОГ по 60.111", (None, "150.00", "170.00", "20.00", None, None)), ("60.112", (None,) * 6), ("П003258", (None, "10.00", "10.00", None, None, None)), ('ООО "РОМАШКА"', (None,) * 6), ("ДП1;1", (None, "10.00", "10.00", None, None, None)), ("ИТОГ по 60.112", (None, "10.00", "10.00", None, None, None)), ("ИТОГ", (None, "160.00", "180.00", "20.00", None, None)), ] sheet = _flat_sheet({}, 6, []) _put_by_counterparty_head(sheet) sheet.put(3, 2, "Счет Поставщика") sheet.put(3, 3, "60*") sheet.put(4, 2, "Счет Клиента") sheet.put(8, 3, "Поставщики") for offset, (text, sums) in enumerate(table): row = 9 + offset sheet.put(row, 1, text) for col, value in enumerate(sums, start=2): if value is not None: sheet.put(row, col, D(value)) return [sheet] def _s47() -> list[SheetSpec]: """Иерархия по отступам там, где отступа не хватает (AURORA-1858): три листа «Субконто» без вида аналитики в шапке. Лист «68»: подуровень «68.01.1» на том же отступе, что «68.01». Лист «90»: безымянная строка закрытия сразу за счётом (пустая ячейка отступа не хранит). Лист «91»: аналитика на одном отступе, а вложена по суммам (100 = 60 + 40; 30 = 20 + 10; 40 ⊃ 40).""" head = {1: 'ООО "Октябрьское"', 3: "Период: 9 месяцев 2024 г."} def sheet(account: str, rows: Sequence[FlatRow]) -> SheetSpec: spec = _flat_sheet({**head, 2: f"Оборотно-сальдовая ведомость по счету {account}"}, 5, rows) spec.name = f"{account} сч." return spec tax = (None, None, "2333.00", "2624.00", None, "291.00") tax_old = (None, "15687.00", "45332.00", "45332.00", None, "15687.00") rows_68: list[FlatRow] = [ ("68.01", tax, 0), ("68.01.1", tax, 0), ("Налог (взносы): начислено / уплачено", tax, 1), ("68.10", tax_old, 0), ("Налог (взносы): начислено / уплачено", tax_old, 1), ("Итого", (None, "15687.00", "47665.00", "47956.00", None, "15978.00"), 0), ] income = (None, None, None, "300.00", None, "300.00") expense = (None, None, "200.00", None, "200.00", None) closing = (None, None, "500.00", "300.00", "200.00", None) rows_90: list[FlatRow] = [ ("90.01", income, 0), ("90.01.1", income, 0), ("Аренда", (None, None, None, "200.00", None, None), 1), ("Прочие", (None, None, None, "100.00", None, None), 1), ("90.07", expense, 0), ("90.07.1", expense, 0), ("Расходы по аренде", (None, None, "200.00", None, None, None), 1), ("90.09", closing, 0), ("", (None, None, "500.00", "300.00", None, None), 0), ("Итого", (None, None, "700.00", "600.00", "400.00", "300.00"), 0), ] def turn(debit: str | None, credit: str | None) -> tuple[str | None, ...]: return (None, None, debit, credit, None, None) rows_91: list[FlatRow] = [ ("91.01", (None, None, None, "100.00", None, "100.00"), 0), ("Проценты к получению", turn(None, "70.00"), 1), ("Проценты по вкладам", turn(None, "70.00"), 1), ("Прочие доходы", turn(None, "30.00"), 1), ("Пени", turn(None, "20.00"), 1), ("Штрафы", turn(None, "10.00"), 1), ("91.02", (None, None, "170.00", None, "170.00", None), 0), ("91.02.1", (None, None, "170.00", None, "170.00", None), 0), ("Проценты к уплате", turn("100.00", None), 1), ("Проценты по займам", turn("60.00", None), 1), ("Проценты по кредитам", turn("40.00", None), 1), ("Прочие расходы", turn("30.00", None), 1), ("Затраты на нужды", turn("20.00", None), 1), ("Штрафы по договорам", turn("10.00", None), 1), ("Операционные расходы", turn("40.00", None), 1), ("Услуги банка", turn("40.00", None), 1), ("91.09", (None, None, None, "70.00", None, "70.00"), 0), ("", turn(None, "70.00"), 0), ("Итого", (None, None, "170.00", "170.00", "170.00", "170.00"), 0), ] return [sheet("68", rows_68), sheet("90", rows_90), sheet("91", rows_91)] def _s28() -> list[SheetSpec]: """Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится.""" tree = _inn_layout_tree() tree.kids[0].kids[0].inn = "7701234567" root = inn_rows_tree(tree, "above_sibling", sums=False) return [build_osv(OsvSpec(period="1 квартал 2025 г.", inn_col=None, first_sum_col=5), root)] def _inn_layout(layout: InnLayout, *, sums: bool) -> Callable[[], list[SheetSpec]]: def build() -> list[SheetSpec]: root = inn_rows_tree(_inn_layout_tree(), layout, sums=sums) return [build_osv(OsvSpec(period="1 квартал 2025 г.", inn_col=None, first_sum_col=5), root)] return build # --- ОСВ других учётных систем (#81): контрольный набор. s48–s54 — позитивы, s55–s57 — негативы --- PERIOD_TAIL = "по счету 60 за 1 квартал 2025 г." NAVISION_GROUPS = { "opening": ("НачалоДт", "НачалоКт"), "turnover": ("ОборотДт", "ОборотКт"), "closing": ("КонецДт", "КонецКт"), } def _titled(name: str, title: str, *, title_col: int = 1, total_text: str | None = None) -> SheetSpec: spec = OsvSpec(inn_col=5, first_sum_col=7, title=title, title_col=title_col, total_text=total_text) return build_osv(spec, base_tree(), name) def _s48() -> list[SheetSpec]: """Названия ведомостей других систем и отчётов 1С (AURORA-1717) и итог «ВСЕГО» (AURORA-1732): по листу на название; название — в разных колонках, верхним регистром с переносом строки.""" return [ _titled("ОВ", f"Оборотная ведомость {PERIOD_TAIL}", title_col=3), _titled("ОСВ сокр", "ОСВ по сч. 60 за 1 квартал 2025 г."), _titled("Оборотный баланс", f"Оборотный баланс {PERIOD_TAIL}", title_col=7), _titled("Навижн", "Фин. Обороты Листок за 1 квартал 2025 г.", title_col=4), _titled("Главная книга", f"Отчет по Главной книге {PERIOD_TAIL}", title_col=3, total_text="ВСЕГО"), _titled("Верхний регистр", f"ОБОРОТНАЯ\nВЕДОМОСТЬ {PERIOD_TAIL}"), _titled("Вторая колонка", f"Оборотная ведомость по счетам {PERIOD_TAIL}", title_col=2, total_text="Всего "), ] def _s49_sheets(hierarchy: Hierarchy = "outline") -> list[SheetSpec]: """«Остаток на начало/конец» над «Дебет/Кредит» и «Оборот по дебету/кредиту» в ячейке группы (КТЗ); преамбула «Остаток на начало: …» над таблицей; второй лист — «Остаток по дебету на начало» (сторона внутри названия).""" first = build_osv( OsvSpec( inn_col=5, first_sum_col=7, hierarchy=hierarchy, title=f"Оборотная ведомость {PERIOD_TAIL}", group_titles={ **GROUP_TITLES, "opening": "Остаток на начало периода", "closing": "Остаток на конец периода", }, split_groups={"turnover": ("Оборот по дебету", "Оборот по кредиту")}, ), base_tree(), "Остатки", ) first.put(5, 1, "Остаток на начало: 0") first.put(5, 3, "Остаток на конец: 0") second = build_osv( OsvSpec( inn_col=None, first_sum_col=3, hierarchy=hierarchy, title=f"Оборотная ведомость {PERIOD_TAIL}", split_groups={ "opening": ("Остаток по дебету на начало", "Остаток по кредиту на начало"), "turnover": ("Оборот по дебету", "Оборот по кредиту"), "closing": ("Остаток по дебету на конец", "Остаток по кредиту на конец"), }, ), receivables_tree(), "Остатки по сторонам", ) return [first, second] def _s49() -> list[SheetSpec]: return _s49_sheets() def _s50() -> list[SheetSpec]: """SAP: «Сальдо вх/исх Дт/Кт» (Виола, обороты 1С-вида — «Дт оборот» это AURORA-1722) и сторона, приклеенная к названию группы «Сальдо НачальноеДТВВ» / «ОборотыДтВВ» (НМЖК); контрагент «ООО Дтвв-Сервис» — не сторона.""" sap = OsvSpec( inn_col=None, first_sum_col=3, title=f"Отчет по Главной книге {PERIOD_TAIL}", split_groups={ "opening": ("Сальдо вх Дт", "Сальдо вх Кт"), "turnover": ("Оборот Дт", "Оборот Кт"), "closing": ("Сальдо исх Дт", "Сальдо исх Кт"), }, ) glued = OsvSpec( inn_col=None, first_sum_col=3, split_groups={ "opening": ("Сальдо НачальноеДТВВ", "Сальдо НачальноеКТВВ"), "turnover": ("ОборотыДтВВ", "ОборотыКтВВ"), "closing": ("Сальдо КонечноеДТВВ", "Сальдо КонечноеКТВВ"), }, ) tree = base_tree() tree.kids[0].kids[0].text = "ООО Дтвв-Сервис" return [build_osv(sap, base_tree(), "SAP"), build_osv(glued, tree, "НМЖК")] def _navision_sheet(name: str = "ОВ", hierarchy: Hierarchy = "outline", top_padding: int = 0) -> SheetSpec: spec = OsvSpec( untitled=True, top_padding=top_padding, inn_col=None, first_sum_col=2, hierarchy=hierarchy, split_groups=NAVISION_GROUPS, ) return build_osv(spec, base_tree(), name) def _s51() -> list[SheetSpec]: """Navision без названия ведомости: таблица с первой строки, «НачалоДт | … | КонецКт» (Айсберри); запасной признак.""" return [_navision_sheet()] def _s52() -> list[SheetSpec]: """Форма ББ, лист Navision без названия, посторонний лист: форма остаётся формой, «Справка» — прочим.""" return [statement_sheet(), _navision_sheet(), reference_sheet()] def _s53() -> list[SheetSpec]: """s49 в xls (BIFF5 без объединений и outline): отступ пробелами.""" return _s49_sheets("spaces") def _s54() -> list[SheetSpec]: """s51 в xlsb (без объединений и outline): отступ пробелами.""" return [_navision_sheet(hierarchy="spaces")] def _s55() -> list[SheetSpec]: """Негатив: названия, похожие на ведомость, но не ведомость (не с начала ячейки, «ОСВ за квартал по счетам …» в перечне документов шаблона банка, «Оборотный капитал»), и отчёт 1С «Обороты счета» с колонками корр. счетов между «Оборот Дт» и «Оборот Кт» (R-1) — все листы прочие.""" return [ build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree(), "ОСВ 60"), table_sheet( "Справка", [ ["Справка: оборотная ведомость прилагается"], [], ["Показатель", "Сумма"], ["Выручка", D("100000.00")], ], ), table_sheet( "Входные данные", [ ["Перечень документов к заявке"], ["№", "Документ", "Срок"], [1, "Расшифровка дебиторской задолженности", "ежеквартально"], [2, "ОСВ за квартал по счетам бухгалтерского учета 02, 58, 60, 62, 66, 67, 76", "ежеквартально"], ], ), table_sheet( "Капитал", [ ["Оборотный капитал и его структура"], [], ["Показатель", "На начало", "На конец"], ["Запасы", D("500.00"), D("700.00")], ], ), table_sheet( "Обороты счета", [ ["Обороты счета 60 за 9 месяцев 2025 г."], [], ["Счет", "Начальное сальдо Дт", "Начальное сальдо Кт", "Оборот Дт", "51", "68", "Оборот Кт"] + ["20", "Конечное сальдо Дт", "Конечное сальдо Кт"], ["60", D("100.00"), None, D("50.00"), D("30.00"), D("20.00"), D("40.00"), D("40.00"), D("110.00")], ["Итого", D("100.00"), None, D("50.00"), D("30.00"), D("20.00"), D("40.00"), D("40.00"), D("110.00")], ], ), ] def _s56() -> list[SheetSpec]: """Негатив: безымянные таблицы, почти ОСВ — расшифровка «Остаток на начало | Поступило | Выбыло | Остаток на конец», нет группы «начало», свёрнутое начало, полный заголовок ниже 30-й строки — все прочие (D1, R-2, R-3).""" return [ build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree(), "ОСВ 60"), table_sheet( "Расшифровка", [ ["Наименование", "Остаток на начало", "Поступило", "Выбыло", "Остаток на конец"], ["Материалы", D("100.00"), D("50.00"), D("30.00"), D("120.00")], ], ), table_sheet( "Без начала", [ ["Счет", "Оборот Дт", "Оборот Кт", "Сальдо на конец Дт", "Сальдо на конец Кт"], ["60", D("10.00"), D("5.00"), D("5.00"), None], ], ), table_sheet( "Свёрнутое начало", [ ["Счет", "Сальдо на начало", "Оборот Дт", "Оборот Кт", "Сальдо на конец Дт", "Сальдо на конец Кт"], ["60", D("100.00"), D("10.00"), D("5.00"), D("105.00"), None], ], ), _navision_sheet("Ниже 30", top_padding=30), _preamble_navision_sheet(), ] def _preamble_navision_sheet() -> SheetSpec: """Полная шапка Navision, над ней преамбула «Обороты за период: … | Остаток на конец: …»: разбор берёт строку преамбулы, значит, и запасной признак смотрит её — лист прочий, книга не падает (ревью #81, Important-1).""" sheet = _navision_sheet("Преамбула", top_padding=1) sheet.put(1, 1, "Обороты за период: 50") sheet.put(1, 2, "Остаток на конец: 150") return sheet def _s57() -> list[SheetSpec]: """Негатив: форма ББ с примечанием «см. оборотную ведомость …» и текстом «Остаток на начало периода …», за ней лист слабого продолжения (одна строка с кодом) — форма и продолжение остаются формой.""" form = statement_sheet() form.put(9, 1, "Примечание: см. оборотную ведомость по сч. 60") form.put(10, 1, "Остаток на начало периода — по данным учёта") continuation = table_sheet("Баланс (2)", [["Капитал и резервы", "1300", D("500000.00")]]) return [form, continuation] 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_xls(sheets: Sequence[SheetSpec]) -> bytes: """xls BIFF5 без записи CODEPAGE (строки cp1251), без стилей: отступ — ведущие пробелы (`hierarchy="spaces"`).""" return write_xls( {spec.name: {key: cell.value for key, cell in spec.cells.items()} for spec in sheets}, codepage=None ) 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), "s20_inn_above_sibling_blank.xlsx": (_inn_layout("above_sibling", sums=False), to_xlsx), "s21_inn_above_sibling_sums.xlsx": (_inn_layout("above_sibling", sums=True), to_xlsx), "s22_inn_above_parent_blank.xlsx": (_inn_layout("above_parent", sums=False), to_xlsx), "s23_inn_above_parent_sums.xlsx": (_inn_layout("above_parent", sums=True), to_xlsx), "s24_inn_below_child_blank.xlsx": (_inn_layout("below_child", sums=False), to_xlsx), "s25_inn_below_child_sums.xlsx": (_inn_layout("below_child", sums=True), to_xlsx), "s26_inn_below_sibling_sums.xlsx": (_inn_layout("below_sibling", sums=True), to_xlsx), "s27_inn_below_sibling_blank.xlsx": (_inn_layout("below_sibling", sums=False), to_xlsx), "s28_inn_bad_checksum.xlsx": (_s28, to_xlsx), "s29_xls_no_codepage.xls": (_s29, to_xls), "s30_val_kol_vo_materials.xlsx": (_s30, to_xlsx), "s31_rows_below_total.xlsx": (_s31, to_xlsx), "s32_total_with_suffix.xlsx": (_s32, to_xlsx), "s33_indicator_column_bu_nu.xlsx": (_s33, to_xlsx), "s34_indicator_column_qty_val_diff.xlsx": (_s34, to_xlsx), "s35_currency_slices.xlsx": (_s35, to_xlsx), "s36_no_data_type_header.xlsx": (_s36, to_xlsx), "s40_flat_header_account.xlsx": (_s40, to_xlsx), "s41_flat_chart_of_accounts.xlsx": (_s41, to_xlsx), "s42_subaccounts_totals_reversed.xlsx": (_s42, to_xlsx), "s43_subkonto_unnamed_60.xlsx": (_s43, to_xlsx), "s44_subkonto_unnamed_10.xlsx": (_s44, to_xlsx), "s45_account_label_value_cells.xlsx": (_s45, to_xlsx), "s46_counterparty_code_blocks.xlsx": (_s46, to_xlsx), "s47_indent_hierarchy_code_sums.xlsx": (_s47, to_xlsx), "s48_other_system_titles.xlsx": (_s48, to_xlsx), "s49_balance_and_turnover_by_side.xlsx": (_s49, to_xlsx), "s50_sap_in_out_glued_side.xlsx": (_s50, to_xlsx), "s51_untitled_navision.xlsx": (_s51, to_xlsx), "s52_mixed_book.xlsx": (_s52, to_xlsx), "s53_balance_by_side.xls": (_s53, to_xls), "s54_untitled_navision.xlsb": (_s54, to_xlsb), "s55_not_osv_titles.xlsx": (_s55, to_xlsx), "s56_almost_osv_tables.xlsx": (_s56, to_xlsx), "s57_form_mentions_osv.xlsx": (_s57, 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…s36, s40…s57 (xlsx, xlsb и xls) в указанный каталог.", ) 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())