Andrey Epifancev bc143ef140 test: golden-снимки, линтер координат, покрытие словарей; make golden-update
- golden_helpers, scripts/golden_update.py (--check), test_golden на 66 ОСВ и синтетике; make golden-update/golden-check/test-fixtures
- тест покрытия словарей (R-22): счётчик срабатываний Pattern; из osv_headers.yaml удалены неиспользуемые service_columns kpp/code/quantity/currency
- синтетика s14–s16 в генераторе и таблице ожиданий; skip вместо падения при отсутствии osv/synthetic (T11.1); комментарий R-21 в s07 (T11.2)
- линтер: предел адреса 10 вместо 4 (T3.1), обращение cell(3, 4) (T3.8); реальные файлы не фиксируют BU/RUB (T3.9)
- README: golden-процесс; отмечены Task 12 и пункты 4.2, 4.3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 15:28:41 +04:00

683 lines
30 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); описание — `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"]
# --- Дерево ведомости ---
@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"
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`
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, "ИНН")
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
def _row_cells(sheet: SheetSpec, spec: OsvSpec, row: int, entry: tuple[Node, int], six: Six) -> None:
"""Одна строка данных (`entry` — узел и его глубина): текст с оформлением иерархии, ИНН/КПП и суммы."""
node, depth = entry
mode = spec.hierarchy
text = INDENT_STEP * depth + node.text if mode == "spaces" else node.text
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:
sheet.put(row, spec.inn_col, node.inn)
if spec.kpp_col is not None and node.kpp:
sheet.put(row, spec.kpp_col, node.kpp)
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, "Счет", "Контрагенты")
_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 _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),
}
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…s16 (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())