1117 lines
55 KiB
Python
1117 lines
55 KiB
Python
"""Генератор синтетических ОСВ (Срез 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 заняты
|
||
другим треком); описание — `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", "650.00", "750.00", "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", "5000.00", "4991.00", "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", "5700.00", "6600.00", 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 _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),
|
||
}
|
||
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())
|