- OQ-100: ИНН в конце названия («Полиграф, 3528315710», «(ИНН …)», пробел) уходит в counterparty_inn, имя — без ИНН и разделителя; колонка ИНН приоритетнее; только для COUNTERPARTY. - OQ-130: строка без сумм с одним ИНН над строкой с суммами того же уровня склеивается со следующей и не уходит в OTHER. - «ИНН/КПП» в ячейке колонки ИНН даёт ИНН. - Правила — osv_headers.yaml (counterparty_inn_suffix, inn_only_row, inn_kpp_cell), не код. - Синтетика s17–s19 (генератор, тесты); golden — в репозитории fixtures (feat/9-golden). Stacked on #13 (feat/11-dkb-formats). Closes #9 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
745 lines
33 KiB
Python
745 lines
33 KiB
Python
"""Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`.
|
||
|
||
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
|
||
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9); описание —
|
||
`osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl +
|
||
`xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок
|
||
записи фиксирован.
|
||
|
||
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
|
||
родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
|
||
строк в файлах разные: парсер не должен на них полагаться.
|
||
"""
|
||
|
||
import argparse
|
||
import datetime
|
||
import io
|
||
import re
|
||
import sys
|
||
import zipfile
|
||
from collections.abc import Callable, Sequence
|
||
from dataclasses import dataclass, field
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
from typing import Literal
|
||
|
||
import openpyxl
|
||
from openpyxl.styles import Alignment, Font
|
||
|
||
from tests.synthetic.xlsb_writer import write_xlsb
|
||
|
||
__all__ = ["FILE_NAMES", "generate_all", "main"]
|
||
|
||
FIXED_TIME = datetime.datetime(2026, 1, 1, 0, 0, 0)
|
||
ZIP_DATE_TIME = (2026, 1, 1, 0, 0, 0)
|
||
CREATOR = "arm-ka synthetic"
|
||
SHEET = "Лист_1"
|
||
ORG = 'ООО "Тестовая организация"'
|
||
D = Decimal
|
||
ZERO = D(0)
|
||
INDENT_STEP = " " # ведущие пробелы одного уровня, когда отступ ячейки недоступен (xlsb)
|
||
|
||
Money = D | None
|
||
Pair = tuple[Money, Money]
|
||
Six = tuple[D, D, D, D, D, D]
|
||
Hierarchy = Literal["outline", "indent", "spaces", "none"]
|
||
NumStyle = Literal["num", "text", "text_nbsp", "text_narrow"]
|
||
# Как ИНН контрагента записан в файле: отдельной колонкой, в тексте названия (разные разделители), отдельной строкой
|
||
# над названием, склеенным «ИНН/КПП» в ячейке колонки ИНН
|
||
InnStyle = Literal["column", "comma", "paren_label", "label", "space", "above", "kpp_cell"]
|
||
|
||
|
||
# --- Дерево ведомости ---
|
||
|
||
|
||
@dataclass
|
||
class Node:
|
||
"""Строка ведомости. Лист: свои начало и обороты, конец вычисляется; родитель: сумма детей по колонкам.
|
||
|
||
`indicator` — строка «Вал.»/«Кол.»/«USD» под контрагентом: свои суммы, в сумму родителя не входит.
|
||
"""
|
||
|
||
text: str
|
||
kids: list["Node"] = field(default_factory=list)
|
||
inn: str | None = None
|
||
kpp: str | None = None
|
||
opening: Pair = (None, None)
|
||
turnover: Pair = (None, None)
|
||
indicator: bool = False
|
||
num_style: NumStyle = "num"
|
||
inn_style: InnStyle = "column"
|
||
closing_debit_delta: D = ZERO # порча суммы (s10): к записанному `closing_debit` прибавляется, родитель не меняется
|
||
|
||
def children(self) -> list["Node"]:
|
||
return [kid for kid in self.kids if not kid.indicator]
|
||
|
||
def six(self) -> Six:
|
||
"""Шесть сумм по колонкам: начало Д/К, обороты Д/К, конец Д/К (без порчи)."""
|
||
children = self.children()
|
||
if children:
|
||
parts = [kid.six() for kid in children]
|
||
return tuple(sum((part[i] for part in parts), ZERO) for i in range(6)) # type: ignore[return-value]
|
||
opening_debit, opening_credit = (value or ZERO for value in self.opening)
|
||
turnover_debit, turnover_credit = (value or ZERO for value in self.turnover)
|
||
net = opening_debit - opening_credit + turnover_debit - turnover_credit
|
||
return (
|
||
opening_debit,
|
||
opening_credit,
|
||
turnover_debit,
|
||
turnover_credit,
|
||
net if net > 0 else ZERO,
|
||
-net if net < 0 else ZERO,
|
||
)
|
||
|
||
|
||
def flatten(node: Node, depth: int = 0) -> list[tuple[Node, int]]:
|
||
"""Строки в порядке обхода в глубину: узел, его глубина."""
|
||
found = [(node, depth)]
|
||
for kid in node.kids:
|
||
found.extend(flatten(kid, depth + 1))
|
||
return found
|
||
|
||
|
||
def leaf(text: str, opening: Pair, turnover: Pair, **kwargs: object) -> Node:
|
||
return Node(text, opening=opening, turnover=turnover, **kwargs) # type: ignore[arg-type]
|
||
|
||
|
||
def money(value: str | None) -> Money:
|
||
return None if value is None else D(value)
|
||
|
||
|
||
def pair(debit: str | None, credit: str | None) -> Pair:
|
||
return money(debit), money(credit)
|
||
|
||
|
||
def alfa(**kwargs: object) -> Node:
|
||
return leaf("ООО Альфа", pair(None, "250000.50"), pair("180000.25", "420000.00"), inn="7701234567", **kwargs)
|
||
|
||
|
||
def beta(**kwargs: object) -> Node:
|
||
return leaf("ООО Бета", pair("15000.00", None), pair("90000.00", "40000.00"), inn="7707654321", **kwargs)
|
||
|
||
|
||
def ivanov() -> Node:
|
||
return leaf("Иванов Иван", pair(None, "3000.00"), pair("3000.00", None)) # физлицо: без ИНН
|
||
|
||
|
||
def gamma(**kwargs: object) -> Node:
|
||
return leaf("ООО Гамма", pair("120000.00", None), pair("30000.00", "100000.00"), inn="5001234567", **kwargs)
|
||
|
||
|
||
def delta(**kwargs: object) -> Node:
|
||
return leaf("ООО Дельта", pair(None, "8000.10"), pair("8000.10", "12000.35"), inn="7812345678", **kwargs)
|
||
|
||
|
||
def sidorov() -> Node:
|
||
return leaf("ИП Сидоров", pair(None, "1500.00"), pair(None, None), inn="500100732259") # ИНН из 12 цифр
|
||
|
||
|
||
KPP = {"ООО Альфа": "770101001", "ООО Бета": "770701001", "ООО Гамма": "500101001", "ООО Дельта": "781201001"}
|
||
|
||
|
||
def base_tree(account: str = "60", *, kpp: bool = False) -> Node:
|
||
"""Счёт 60: два субсчёта, шесть контрагентов (с ИНН из 10 и 12 цифр и физлицо без ИНН)."""
|
||
kids = {"60.01": [alfa(), beta(), ivanov()], "60.02": [gamma(), delta(), sidorov()]}
|
||
if kpp:
|
||
for group in kids.values():
|
||
for node in group:
|
||
node.kpp = KPP.get(node.text)
|
||
return Node(account, [Node(sub, items) for sub, items in kids.items()])
|
||
|
||
|
||
def receivables_tree() -> Node:
|
||
"""Счёт 62 для второго листа s06."""
|
||
first = leaf("ООО Покупатель-1", pair("500000.00", None), pair("320000.00", "410000.00"), inn="7728123456")
|
||
second = leaf("ООО Покупатель-2", pair(None, "20000.00"), pair("75000.00", "55000.00"), inn="7714987654")
|
||
return Node("62", [Node("62.01", [first, second])])
|
||
|
||
|
||
# --- Лист в памяти ---
|
||
|
||
|
||
@dataclass
|
||
class CellSpec:
|
||
value: str | int | D
|
||
bold: bool = False
|
||
indent: int = 0
|
||
|
||
|
||
@dataclass
|
||
class SheetSpec:
|
||
name: str
|
||
cells: dict[tuple[int, int], CellSpec] = field(default_factory=dict)
|
||
merges: list[tuple[int, int, int, int]] = field(default_factory=list)
|
||
outline: dict[int, int] = field(default_factory=dict)
|
||
|
||
def put(self, row: int, col: int, value: str | int | D, *, bold: bool = False, indent: int = 0) -> None:
|
||
self.cells[(row, col)] = CellSpec(value, bold, indent)
|
||
|
||
def merge(self, top: int, left: int, bottom: int, right: int) -> None:
|
||
if (top, left) != (bottom, right):
|
||
self.merges.append((top, left, bottom, right))
|
||
|
||
|
||
# --- Шапка и таблица ---
|
||
|
||
|
||
@dataclass
|
||
class OsvSpec:
|
||
"""Параметры листа ОСВ: шапка, раскладка колонок и способ выражения иерархии."""
|
||
|
||
account: str = "60"
|
||
period: str = "1 квартал 2025 г."
|
||
data_type_line: str = "Выводимые данные: БУ (данные бухгалтерского учета)"
|
||
currency_line: str = "Валюта БУ, НУ: RUB"
|
||
variant: Literal[1, 2] = 1
|
||
inn_col: int | None = 5
|
||
kpp_col: int | None = None
|
||
first_sum_col: int = 6
|
||
opening: bool = True
|
||
collapsed_closing: bool = False
|
||
hierarchy: Hierarchy = "outline"
|
||
bold_accounts: bool = False
|
||
repeat_header_after: int | None = None # после стольких строк данных повторяется заголовок (печать по страницам)
|
||
detail: str = "Контрагенты, Договоры"
|
||
detail_label: str = "Детализация по субсчетам, субконто" # метка перед перечнем субконто (вариант 2)
|
||
org_name: str | None = ORG # None — в шапке нет строки с названием организации
|
||
group_titles: dict[str, str] | None = None # названия групп сумм вместо `GROUP_TITLES`
|
||
inn_header: str = "ИНН" # подпись колонки ИНН («ИНН/КПП» — склеенная)
|
||
labels_below: bool = False # вариант 1: «Счет» и «Контрагенты» — отдельными строками под подзаголовками сумм
|
||
|
||
|
||
GROUP_TITLES = {
|
||
"opening": "Сальдо на начало периода",
|
||
"turnover": "Обороты за период",
|
||
"closing": "Сальдо на конец периода",
|
||
}
|
||
PAIR_WIDTH = 2 # колонки группы сумм с подзаголовками «Дебет»/«Кредит»
|
||
DEBIT, CREDIT = "Дебет", "Кредит"
|
||
TOTAL_TEXT = "Итого"
|
||
|
||
|
||
def _groups(spec: OsvSpec) -> list[tuple[str, int, int]]:
|
||
"""Группы сумм слева направо: (ключ, первая колонка, ширина)."""
|
||
result: list[tuple[str, int, int]] = []
|
||
col = spec.first_sum_col
|
||
for key in ("opening", "turnover", "closing"):
|
||
if key == "opening" and not spec.opening:
|
||
continue
|
||
width = 1 if key == "closing" and spec.collapsed_closing else PAIR_WIDTH
|
||
result.append((key, col, width))
|
||
col += width
|
||
return result
|
||
|
||
|
||
def _header_rows(sheet: SheetSpec, spec: OsvSpec, top: int, label: str, sub_label: str) -> int:
|
||
"""Блок заголовка таблицы с `top`: строка групп и строка Дебет/Кредит; возвращает номер следующей строки.
|
||
|
||
`labels_below` (только для блока с меткой «Счет»): метки группировок и «ИНН» — отдельными строками под
|
||
Дебет/Кредит (так печатает 1С), в колонках сумм у них пусто.
|
||
"""
|
||
titles = spec.group_titles or GROUP_TITLES
|
||
below = spec.labels_below and bool(label)
|
||
label_row = top + 2 if below else top # строка меток группировок (первая) и колонок ИНН/КПП
|
||
sheet.put(label_row, 1, label)
|
||
sheet.put(label_row + 1, 1, sub_label)
|
||
service_row = label_row + 1 if below else top + 1
|
||
if spec.inn_col is not None:
|
||
sheet.put(service_row, spec.inn_col, spec.inn_header)
|
||
if spec.kpp_col is not None:
|
||
sheet.put(service_row, spec.kpp_col, "КПП")
|
||
for key, col, width in _groups(spec):
|
||
sheet.put(top, col, titles[key])
|
||
sheet.merge(top, col, top, col + width - 1)
|
||
if width == PAIR_WIDTH:
|
||
sheet.put(top + 1, col, DEBIT)
|
||
sheet.put(top + 1, col + 1, CREDIT)
|
||
return top + 4 if below else top + 2
|
||
|
||
|
||
def _header_v1(sheet: SheetSpec, spec: OsvSpec) -> int:
|
||
if spec.org_name is not None:
|
||
sheet.put(1, 1, spec.org_name)
|
||
sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {spec.account} за {spec.period}")
|
||
sheet.put(4, 1, spec.data_type_line)
|
||
sheet.put(7, 1, "Параметры:")
|
||
sheet.put(7, 3, spec.currency_line)
|
||
return _header_rows(sheet, spec, 9, "Счет", "Контрагенты")
|
||
|
||
|
||
def _header_v2(sheet: SheetSpec, spec: OsvSpec) -> int:
|
||
if spec.org_name is not None:
|
||
sheet.put(1, 1, spec.org_name)
|
||
sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {spec.account}")
|
||
sheet.put(3, 1, f"Период: {spec.period}")
|
||
sheet.put(4, 1, f"{spec.detail_label}: {spec.detail}")
|
||
sheet.put(5, 1, spec.data_type_line)
|
||
top = 7
|
||
nxt = _header_rows(sheet, spec, top, "Субконто", "")
|
||
sheet.cells.pop((top + 1, 1), None)
|
||
sheet.merge(top, 1, top + 1, 1)
|
||
return nxt
|
||
|
||
|
||
_INN_IN_NAME = {
|
||
"comma": "{name}, {inn}",
|
||
"paren_label": "{name} (ИНН {inn})",
|
||
"label": "{name} ИНН: {inn}",
|
||
"space": "{name} {inn}",
|
||
}
|
||
|
||
|
||
def _name_with_inn(node: Node) -> str:
|
||
"""Название строки; ИНН дописан в текст, если так задан `inn_style` (иначе — только название)."""
|
||
template = _INN_IN_NAME.get(node.inn_style)
|
||
return node.text if template is None or not node.inn else template.format(name=node.text, inn=node.inn)
|
||
|
||
|
||
def _row_cells( # noqa: PLR0913 - строка листа описывается полностью одним вызовом
|
||
sheet: SheetSpec, spec: OsvSpec, row: int, entry: tuple[Node, int], six: Six, *, blank: bool = False
|
||
) -> None:
|
||
"""Одна строка данных (`entry` — узел и его глубина): текст с оформлением иерархии, ИНН/КПП и суммы."""
|
||
node, depth = entry
|
||
mode = spec.hierarchy
|
||
shown = _name_with_inn(node)
|
||
text = INDENT_STEP * depth + shown if mode == "spaces" else shown
|
||
bold = spec.bold_accounts and bool(node.children())
|
||
sheet.put(row, 1, text, bold=bold, indent=depth if mode == "indent" else 0)
|
||
if mode == "outline" and depth:
|
||
sheet.outline[row] = depth
|
||
if spec.inn_col is not None and node.inn and node.inn_style in ("column", "kpp_cell"):
|
||
sheet.put(row, spec.inn_col, f"{node.inn}/{node.kpp}" if node.inn_style == "kpp_cell" else node.inn)
|
||
if spec.kpp_col is not None and node.kpp:
|
||
sheet.put(row, spec.kpp_col, node.kpp)
|
||
if blank:
|
||
return # строка без сумм (ИНН над названием)
|
||
values = list(six)
|
||
values[4] += node.closing_debit_delta
|
||
for key, col, width in _groups(spec):
|
||
index = {"opening": 0, "turnover": 2, "closing": 4}[key]
|
||
debit, credit = values[index], values[index + 1]
|
||
if width == 1: # свёрнутое сальдо: одна колонка, кредитовое — со знаком «минус»
|
||
_put_number(sheet, row, col, debit - credit, node.num_style)
|
||
else:
|
||
_put_number(sheet, row, col, debit, node.num_style)
|
||
_put_number(sheet, row, col + 1, credit, node.num_style)
|
||
|
||
|
||
NBSP = chr(0x00A0) # неразрывный пробел
|
||
NARROW_NBSP = chr(0x202F) # узкий неразрывный пробел
|
||
_SEPARATORS: dict[str, str] = {"text": " ", "text_nbsp": NBSP, "text_narrow": NARROW_NBSP}
|
||
_DASHES: dict[str, str] = {"text": "—", "text_nbsp": "–", "text_narrow": "-"}
|
||
|
||
|
||
def _text_number(value: D, style: str) -> str:
|
||
"""Число как печатает человек: «1 234 567,89», «(1 234,00)» для отрицательных, прочерк для нуля."""
|
||
if value == 0:
|
||
return _DASHES[style]
|
||
integer, fraction = f"{abs(value):.2f}".split(".")
|
||
groups: list[str] = []
|
||
while integer:
|
||
groups.insert(0, integer[-3:])
|
||
integer = integer[:-3]
|
||
body = f"{_SEPARATORS[style].join(groups)},{fraction}"
|
||
return f"({body})" if value < 0 else body
|
||
|
||
|
||
def _put_number(sheet: SheetSpec, row: int, col: int, value: D, style: str) -> None:
|
||
if style == "num":
|
||
if value != 0:
|
||
sheet.put(row, col, value)
|
||
return
|
||
sheet.put(row, col, _text_number(value, style))
|
||
|
||
|
||
def build_osv(spec: OsvSpec, root: Node, name: str = SHEET) -> SheetSpec:
|
||
"""Лист ОСВ по дереву `root` и параметрам `spec`."""
|
||
sheet = SheetSpec(name)
|
||
next_row = _header_v1(sheet, spec) if spec.variant == 1 else _header_v2(sheet, spec)
|
||
for index, (node, depth) in enumerate(flatten(root)):
|
||
if spec.repeat_header_after == index:
|
||
# Печать по страницам: пустая строка и копия блока заголовка
|
||
next_row = _header_rows(sheet, spec, next_row + 1, "Счет", "Контрагенты")
|
||
if node.inn_style == "above" and node.inn:
|
||
# ИНН отдельной строкой над названием, без сумм, на том же уровне оформления
|
||
inn_line = Node(node.inn)
|
||
_row_cells(sheet, spec, next_row, (inn_line, depth), (ZERO,) * 6, blank=True)
|
||
next_row += 1
|
||
_row_cells(sheet, spec, next_row, (node, depth), node.six())
|
||
next_row += 1
|
||
total = SheetSpec("")
|
||
_row_cells(total, spec, 1, (Node(TOTAL_TEXT), 0), root.six())
|
||
for (_, col), cell in total.cells.items():
|
||
sheet.cells[(next_row, col)] = CellSpec(cell.value, bold=spec.bold_accounts)
|
||
return sheet
|
||
|
||
|
||
# --- Прочие листы ---
|
||
|
||
|
||
def reference_sheet(name: str = "Справка") -> SheetSpec:
|
||
"""Посторонний лист: таблица без признаков ОСВ и форм."""
|
||
sheet = SheetSpec(name)
|
||
sheet.put(1, 1, "Справка о составе группы")
|
||
sheet.put(3, 1, "Организация")
|
||
sheet.put(3, 2, "ИНН")
|
||
sheet.put(3, 3, "Доля, %")
|
||
sheet.put(4, 1, 'ООО "Тестовая организация"')
|
||
sheet.put(4, 2, "7700000001")
|
||
sheet.put(4, 3, 100)
|
||
sheet.put(5, 1, "ООО Дочерняя")
|
||
sheet.put(5, 2, "7700000002")
|
||
sheet.put(5, 3, 51)
|
||
return sheet
|
||
|
||
|
||
def statement_sheet(name: str = "Бухгалтерский баланс") -> SheetSpec:
|
||
"""Лист формы ББ (признаки «Бухгалтерский баланс» и код формы 0710001): ОСВ-парсер его пропускает."""
|
||
sheet = SheetSpec(name)
|
||
sheet.put(1, 1, "Бухгалтерский баланс")
|
||
sheet.put(2, 1, "на 30 сентября 2025 г.")
|
||
sheet.put(3, 1, "Форма по ОКУД 0710001")
|
||
sheet.put(5, 1, "Актив")
|
||
sheet.put(5, 2, "Код")
|
||
sheet.put(5, 3, "На 30.09.2025")
|
||
sheet.put(6, 1, "Основные средства")
|
||
sheet.put(6, 2, "1150")
|
||
sheet.put(6, 3, 1200000)
|
||
sheet.put(7, 1, "Запасы")
|
||
sheet.put(7, 2, "1210")
|
||
sheet.put(7, 3, 340000)
|
||
return sheet
|
||
|
||
|
||
def form_sheet(name: str, *lines: str) -> SheetSpec:
|
||
"""Лист формы без таблицы: в шапке только `lines` (так проверяется каждый признак формы по отдельности)."""
|
||
sheet = SheetSpec(name)
|
||
for row, line in enumerate(lines, start=1):
|
||
sheet.put(row, 1, line)
|
||
sheet.put(len(lines) + 2, 1, "Показатель")
|
||
sheet.put(len(lines) + 2, 2, "Код")
|
||
return sheet
|
||
|
||
|
||
# --- Файлы ---
|
||
|
||
|
||
def _s01() -> list[SheetSpec]:
|
||
def contract(text: str, opening: Pair, turnover: Pair) -> Node:
|
||
return leaf(text, opening, turnover)
|
||
|
||
first = Node(
|
||
"ООО Альфа",
|
||
[
|
||
contract("Договор поставки № 15 от 10.01.2026", pair(None, "100000.00"), pair("60000.00", "150000.00")),
|
||
contract("Договор № 7 от 01.02.2026", pair(None, "20000.50"), pair("0.50", "30000.00")),
|
||
],
|
||
inn="7701234567",
|
||
)
|
||
second = Node(
|
||
"ООО Бета",
|
||
[contract("Договор подряда № 3 от 15.01.2026", pair("15000.00", None), pair("90000.00", "40000.00"))],
|
||
inn="7707654321",
|
||
)
|
||
third = Node(
|
||
"ООО Гамма",
|
||
[contract("Договор аренды № 2 от 09.01.2026", pair("120000.00", None), pair("30000.00", "100000.00"))],
|
||
inn="5001234567",
|
||
)
|
||
root = Node("60", [Node("60.01", [first, second]), Node("60.02", [third])])
|
||
spec = OsvSpec(
|
||
period="1 квартал 2026 г.",
|
||
data_type_line="Выводимые данные: Сумма",
|
||
variant=2,
|
||
inn_col=5,
|
||
first_sum_col=6,
|
||
)
|
||
return [build_osv(spec, root)]
|
||
|
||
|
||
def _s02() -> list[SheetSpec]:
|
||
spec = OsvSpec(
|
||
period="2 квартал 2025 г.", currency_line="Валюта БУ, НУ: руб.", hierarchy="indent", inn_col=5, first_sum_col=7
|
||
)
|
||
return [build_osv(spec, base_tree())]
|
||
|
||
|
||
def _s03() -> list[SheetSpec]:
|
||
spec = OsvSpec(period="I кв. 2025 г.", hierarchy="none", bold_accounts=True, inn_col=5, first_sum_col=7)
|
||
return [build_osv(spec, base_tree())]
|
||
|
||
|
||
def _s04() -> list[SheetSpec]:
|
||
first = alfa()
|
||
first.kids = [
|
||
leaf("USD", pair(None, "2500.00"), pair("1800.00", "4200.00"), indicator=True),
|
||
leaf("Кол.", pair("10", None), pair("5", "3"), indicator=True),
|
||
]
|
||
second = beta()
|
||
second.kids = [leaf("Вал.", pair("1000.00", None), pair(None, "400.00"), indicator=True)]
|
||
root = Node("60", [Node("60.01", [first, second]), Node("60.02", [gamma()])])
|
||
return [build_osv(OsvSpec(period="3 квартал 2025 г.", inn_col=5, first_sum_col=7), root)]
|
||
|
||
|
||
def _s05() -> list[SheetSpec]:
|
||
first = leaf(
|
||
"ООО Альфа",
|
||
pair("1234567.89", None),
|
||
pair("500.00", "-1234.00"),
|
||
inn="7701234567",
|
||
num_style="text",
|
||
)
|
||
second = beta(num_style="text_nbsp")
|
||
third = gamma(num_style="text_narrow")
|
||
root = Node("60", [Node("60.01", [first, second]), Node("60.02", [third, delta()])])
|
||
return [build_osv(OsvSpec(period="полугодие 2025 г.", inn_col=4, first_sum_col=5), root)]
|
||
|
||
|
||
def _s06() -> list[SheetSpec]:
|
||
first = build_osv(OsvSpec(account="60", inn_col=5, first_sum_col=6), base_tree(), "ОСВ 60")
|
||
second = build_osv(OsvSpec(account="62", inn_col=3, first_sum_col=4), receivables_tree(), "ОСВ 62")
|
||
return [first, reference_sheet(), second]
|
||
|
||
|
||
def _s07() -> list[SheetSpec]:
|
||
# Смешанные знаки у детей (кредитовое у Альфы, дебетовое у Беты) намеренно проверяют правило R-21: свёрнутое сальдо
|
||
# родителя сверяется по нетто, а не по сумме модулей
|
||
first = leaf("ООО Альфа", pair(None, None), pair("100000.00", "350000.50"), inn="7701234567")
|
||
second = leaf("ООО Бета", pair(None, None), pair("60000.00", "10000.00"), inn="7707654321")
|
||
third = leaf("ООО Гамма", pair(None, None), pair("20000.00", "20000.00"), inn="5001234567")
|
||
fourth = leaf("ООО Дельта", pair(None, None), pair("1000.00", "33000.40"), inn="7812345678")
|
||
root = Node("60", [Node("60.01", [first, second]), Node("60.02", [third, fourth])])
|
||
spec = OsvSpec(period="9 месяцев 2025 г.", opening=False, collapsed_closing=True, inn_col=5, first_sum_col=7)
|
||
return [build_osv(spec, root)]
|
||
|
||
|
||
def _s08() -> list[SheetSpec]:
|
||
spec = OsvSpec(period="период с 01.01.2025 по 31.03.2025", inn_col=5, kpp_col=6, first_sum_col=7)
|
||
return [build_osv(spec, base_tree(kpp=True))]
|
||
|
||
|
||
def _s09() -> list[SheetSpec]:
|
||
named = Node("60.01, Расчеты с поставщиками и подрядчиками", [alfa(), beta()])
|
||
advances = Node("60.02, Расчеты по авансам выданным", [gamma()])
|
||
letter = Node("60.АВ, Расчеты по авансам (буквенный субсчёт)", [delta()])
|
||
root = Node("60", [named, advances, letter])
|
||
return [build_osv(OsvSpec(period="4 квартал 2025 г.", inn_col=5, first_sum_col=7), root)]
|
||
|
||
|
||
def _s10() -> list[SheetSpec]:
|
||
tree = base_tree()
|
||
broken = tree.kids[0].kids[1]
|
||
assert broken.text == "ООО Бета"
|
||
broken.closing_debit_delta = D(1)
|
||
return [build_osv(OsvSpec(inn_col=5, first_sum_col=7), tree)]
|
||
|
||
|
||
def _s11() -> list[SheetSpec]:
|
||
spec = OsvSpec(period="12 месяцев 2025 г.", repeat_header_after=4, inn_col=5, first_sum_col=7)
|
||
return [build_osv(spec, base_tree())]
|
||
|
||
|
||
def _s12() -> list[SheetSpec]:
|
||
spec = OsvSpec(
|
||
period="январь - сентябрь 2025 г.",
|
||
data_type_line="Выводимые данные: НУ (данные налогового учета)",
|
||
inn_col=5,
|
||
first_sum_col=7,
|
||
)
|
||
return [statement_sheet(), build_osv(spec, base_tree(), "ОСВ 60")]
|
||
|
||
|
||
def _s13() -> list[SheetSpec]:
|
||
spec = OsvSpec(
|
||
period="2 квартал 2025 г.", currency_line="Валюта БУ, НУ: руб.", hierarchy="spaces", inn_col=5, first_sum_col=7
|
||
)
|
||
return [build_osv(spec, base_tree())]
|
||
|
||
|
||
def _s14() -> list[SheetSpec]:
|
||
"""Три листа форм, опознаваемые каждый одним своим признаком (покрытие `statement_markers`), и лист ОСВ."""
|
||
return [
|
||
form_sheet("Форма 1", "Форма по ОКУД 0710001"),
|
||
form_sheet("ОФР", "Отчет о финансовых результатах"),
|
||
form_sheet("Форма 2", "Форма по ОКУД 0710002"),
|
||
build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree(), "ОСВ 60"),
|
||
]
|
||
|
||
|
||
def _s15() -> list[SheetSpec]:
|
||
"""Названия групп сумм: «Входящее/Конечное сальдо» на первом листе, «Начальное/Исходящее сальдо» на втором."""
|
||
first = OsvSpec(
|
||
account="60",
|
||
inn_col=5,
|
||
first_sum_col=7,
|
||
group_titles={"opening": "Входящее сальдо", "turnover": "Обороты", "closing": "Конечное сальдо"},
|
||
)
|
||
second = OsvSpec(
|
||
account="62",
|
||
inn_col=3,
|
||
first_sum_col=4,
|
||
group_titles={"opening": "Начальное сальдо", "turnover": "Обороты", "closing": "Исходящее сальдо"},
|
||
)
|
||
return [build_osv(first, base_tree(), "ОСВ 60"), build_osv(second, receivables_tree(), "ОСВ 62")]
|
||
|
||
|
||
def _s16() -> list[SheetSpec]:
|
||
"""Шапка без названия организации; период — только год; валюта с пояснением; метки группировок отдельными
|
||
строками («Счет» среди них); перечень «Детализация: …» без слова «субконто» и группировки вне ключевых."""
|
||
first = OsvSpec(
|
||
account="60",
|
||
period="2024",
|
||
currency_line="Валюта БУ, НУ: RUB (код 643)",
|
||
org_name=None,
|
||
labels_below=True,
|
||
inn_col=5,
|
||
first_sum_col=7,
|
||
)
|
||
second = OsvSpec(
|
||
account="62",
|
||
variant=2,
|
||
period="2024",
|
||
org_name=None,
|
||
detail_label="Детализация по субсчетам",
|
||
detail="Номенклатура, Статьи затрат, Нематериальные активы, Валюта",
|
||
inn_col=None,
|
||
first_sum_col=3,
|
||
)
|
||
return [build_osv(first, base_tree(), "ОСВ 60"), build_osv(second, receivables_tree(), "ОСВ 62")]
|
||
|
||
|
||
# --- Запись файлов ---
|
||
|
||
|
||
_MODIFIED_RE = re.compile(rb"(<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): строка без сумм, того же уровня; колонки ИНН нет."""
|
||
root = Node(
|
||
"60", [Node("60.01", [alfa(inn_style="above"), beta(), ivanov()]), Node("60.02", [gamma(inn_style="above")])]
|
||
)
|
||
return [build_osv(OsvSpec(period="2 квартал 2025 г.", inn_col=None, first_sum_col=5), root)]
|
||
|
||
|
||
def _s19() -> list[SheetSpec]:
|
||
"""Колонка «ИНН/КПП» со склеенным значением «7701234567/770101001» в одной ячейке (OQ-100)."""
|
||
items = [alfa(inn_style="kpp_cell"), beta(inn_style="kpp_cell"), ivanov()]
|
||
for node in items:
|
||
node.kpp = KPP.get(node.text)
|
||
root = Node("60", [Node("60.01", items), Node("60.02", [gamma(), delta()])])
|
||
spec = OsvSpec(period="3 квартал 2025 г.", inn_col=5, inn_header="ИНН/КПП", first_sum_col=6)
|
||
return [build_osv(spec, root)]
|
||
|
||
|
||
def _normalized_zip(data: bytes) -> bytes:
|
||
"""Тот же архив с постоянными метками времени: записей zip и `dcterms:modified` (openpyxl ставит текущее время
|
||
при каждом `save`, заданное в свойствах значение перезаписывает)."""
|
||
out = io.BytesIO()
|
||
with zipfile.ZipFile(io.BytesIO(data)) as source, zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as target:
|
||
for info in source.infolist():
|
||
clone = zipfile.ZipInfo(info.filename, date_time=ZIP_DATE_TIME)
|
||
clone.compress_type = zipfile.ZIP_DEFLATED
|
||
clone.external_attr = info.external_attr
|
||
content = source.read(info.filename)
|
||
if info.filename == _CORE_PART:
|
||
content = _MODIFIED_RE.sub(rb"\g<1>" + _FIXED_W3CDTF + rb"\g<2>", content)
|
||
target.writestr(clone, content)
|
||
return out.getvalue()
|
||
|
||
|
||
def to_xlsx(sheets: Sequence[SheetSpec]) -> bytes:
|
||
book = openpyxl.Workbook()
|
||
default = book.active
|
||
assert default is not None
|
||
book.remove(default)
|
||
book.properties.creator = CREATOR
|
||
book.properties.lastModifiedBy = CREATOR
|
||
book.properties.created = FIXED_TIME
|
||
book.properties.modified = FIXED_TIME
|
||
for spec in sheets:
|
||
ws = book.create_sheet(spec.name)
|
||
for (row, col), cell in sorted(spec.cells.items()):
|
||
target = ws.cell(row, col, cell.value)
|
||
if cell.bold:
|
||
target.font = Font(bold=True)
|
||
if cell.indent:
|
||
target.alignment = Alignment(indent=cell.indent)
|
||
for top, left, bottom, right in spec.merges:
|
||
ws.merge_cells(start_row=top, start_column=left, end_row=bottom, end_column=right)
|
||
for row, level in sorted(spec.outline.items()):
|
||
ws.row_dimensions[row].outline_level = level
|
||
ws.column_dimensions["A"].width = 48
|
||
buffer = io.BytesIO()
|
||
book.save(buffer)
|
||
return _normalized_zip(buffer.getvalue())
|
||
|
||
|
||
def to_xlsb(sheets: Sequence[SheetSpec]) -> bytes:
|
||
"""xlsb без стилей и объединений: отступ уже выражен ведущими пробелами текста (`hierarchy="spaces"`)."""
|
||
return write_xlsb({spec.name: {key: cell.value for key, cell in spec.cells.items()} for spec in sheets})
|
||
|
||
|
||
FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSpec]], bytes]]] = {
|
||
"s01_variant2_header.xlsx": (_s01, to_xlsx),
|
||
"s02_no_outline_indent.xlsx": (_s02, to_xlsx),
|
||
"s03_no_outline_no_indent.xlsx": (_s03, to_xlsx),
|
||
"s04_currency_quantity_rows.xlsx": (_s04, to_xlsx),
|
||
"s05_text_numbers.xlsx": (_s05, to_xlsx),
|
||
"s06_two_osv_and_other_sheet.xlsx": (_s06, to_xlsx),
|
||
"s07_no_opening_collapsed_closing.xlsx": (_s07, to_xlsx),
|
||
"s08_extra_kpp_column.xlsx": (_s08, to_xlsx),
|
||
"s09_subaccount_names.xlsx": (_s09, to_xlsx),
|
||
"s10_broken_sum.xlsx": (_s10, to_xlsx),
|
||
"s11_header_repeat.xlsx": (_s11, to_xlsx),
|
||
"s12_statement_and_osv.xlsx": (_s12, to_xlsx),
|
||
"s13_no_outline_indent.xlsb": (_s13, to_xlsb),
|
||
"s14_statement_forms.xlsx": (_s14, to_xlsx),
|
||
"s15_group_title_synonyms.xlsx": (_s15, to_xlsx),
|
||
"s16_header_synonyms.xlsx": (_s16, to_xlsx),
|
||
"s17_inn_in_name.xlsx": (_s17, to_xlsx),
|
||
"s18_inn_row_above.xlsx": (_s18, to_xlsx),
|
||
"s19_inn_kpp_cell.xlsx": (_s19, to_xlsx),
|
||
}
|
||
FILE_NAMES = tuple(FILES)
|
||
|
||
|
||
def generate_all(directory: Path) -> list[Path]:
|
||
"""Записывает файлы набора в `directory` (создаётся); возвращает пути в порядке `FILE_NAMES`."""
|
||
directory.mkdir(parents=True, exist_ok=True)
|
||
written: list[Path] = []
|
||
for name, (build, serialize) in FILES.items():
|
||
path = directory / name
|
||
path.write_bytes(serialize(build()))
|
||
written.append(path)
|
||
return written
|
||
|
||
|
||
def main(argv: Sequence[str] | None = None) -> int:
|
||
parser = argparse.ArgumentParser(
|
||
prog="python -m tests.synthetic.generate",
|
||
description="Синтетические ОСВ s01…s19 (xlsx и один xlsb) в указанный каталог.",
|
||
)
|
||
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
|
||
args = parser.parse_args(argv)
|
||
for path in generate_all(args.directory):
|
||
print(path)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|