Andrey Epifancev ef14747f49 fix(osv): счёт шапки «Счет Поставщика | 60*» и голые коды контрагентов (AURORA-1714, #71)
Метки «Счет Поставщика»/«Счет Клиента» добавлены в header_markers.account; код счёта читается из соседней ячейки строки,
«*» отбрасывается. При известном счёте шапки голый числовой код, не совпадающий с ним, не считается счётом.
Синтетика s45, тест на книге openpyxl, сценарий в спеке osv-parsing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:16:21 +04:00

1136 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Генератор синтетических ОСВ (Срез 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*» в соседних ячейках (#71); описание —
`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: «Счет» и «Контрагенты» — отдельными строками под подзаголовками сумм
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.twin or node).six(), blank=node.blank)
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
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"(<dcterms:modified[^>]*>)[^<]*(</dcterms:modified>)")
_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 _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({1: "Оборотно-сальдовая ведомость", 2: "За период"}, 6, rows)
sheet.put(2, 3, "01.01.25..30.06.25")
sheet.put(3, 2, "Счет Поставщика")
sheet.put(3, 3, "60*")
sheet.put(4, 2, "Счет Клиента")
return [sheet]
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
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),
}
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…s44 (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())