test: генератор синтетики и сценарии спеки на синтетических файлах
api/tests/synthetic: детерминированный генератор s01…s13 (openpyxl, метки времени книги и zip зафиксированы), минимальный писатель BIFF12 для s13.xlsb (ruling R-9, читается pyxlsb), тесты сценариев спеки osv-parsing на файлах из fixtures/osv/synthetic/ (таблица «файл → ожидания», s13 равен s02 кроме предупреждения xlsb), тест побайтовой воспроизводимости генерации. synthetic_files() учитывает только файлы Excel (README.md каталога не файл набора). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
621c0b2dc4
commit
e1559c9770
@ -38,8 +38,13 @@ def osv_fixture_files(root: Path) -> list[Path]:
|
||||
|
||||
|
||||
def synthetic_files(root: Path) -> list[Path]:
|
||||
"""Файлы `root/osv/synthetic/*` (скрытые, вроде `.gitkeep`, не считаются); отсортированы."""
|
||||
"""Файлы Excel `root/osv/synthetic/*.xls|xlsx|xlsb` (скрытые и пояснения вроде `README.md` не считаются);
|
||||
отсортированы."""
|
||||
directory = root / "osv" / _SYNTHETIC
|
||||
if not directory.is_dir():
|
||||
return []
|
||||
return sorted(path for path in directory.iterdir() if path.is_file() and not path.name.startswith("."))
|
||||
return sorted(
|
||||
path
|
||||
for path in directory.iterdir()
|
||||
if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in OSV_EXTENSIONS
|
||||
)
|
||||
|
||||
1
api/tests/synthetic/__init__.py
Normal file
1
api/tests/synthetic/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Синтетические ОСВ для форматов, которых нет в реальных файлах: генератор и тесты сценариев спеки."""
|
||||
600
api/tests/synthetic/generate.py
Normal file
600
api/tests/synthetic/generate.py
Normal file
@ -0,0 +1,600 @@
|
||||
"""Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`.
|
||||
|
||||
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл (см. `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 = "Контрагенты, Договоры"
|
||||
|
||||
|
||||
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`: строка групп и строка Дебет/Кредит; возвращает номер следующей строки."""
|
||||
sheet.put(top, 1, label)
|
||||
sheet.put(top + 1, 1, sub_label)
|
||||
if spec.inn_col is not None:
|
||||
sheet.put(top + 1, spec.inn_col, "ИНН")
|
||||
if spec.kpp_col is not None:
|
||||
sheet.put(top + 1, spec.kpp_col, "КПП")
|
||||
for key, col, width in _groups(spec):
|
||||
sheet.put(top, col, GROUP_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 + 2
|
||||
|
||||
|
||||
def _header_v1(sheet: SheetSpec, spec: OsvSpec) -> int:
|
||||
sheet.put(1, 1, ORG)
|
||||
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:
|
||||
sheet.put(1, 1, ORG)
|
||||
sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {spec.account}")
|
||||
sheet.put(3, 1, f"Период: {spec.period}")
|
||||
sheet.put(4, 1, f"Детализация по субсчетам, субконто: {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 _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]:
|
||||
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())]
|
||||
|
||||
|
||||
# --- Запись файлов ---
|
||||
|
||||
|
||||
_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),
|
||||
}
|
||||
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…s13 (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())
|
||||
72
api/tests/synthetic/test_generate.py
Normal file
72
api/tests/synthetic/test_generate.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""Генератор синтетики: детерминированность, состав набора и читаемость xlsb-писателя (не требует каталога fixtures)."""
|
||||
|
||||
import re
|
||||
import time
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from src.parser.readers import read_book
|
||||
from tests.synthetic.generate import FILE_NAMES, generate_all
|
||||
from tests.synthetic.xlsb_writer import write_xlsb
|
||||
|
||||
# Сколько ждать между двумя генерациями: у записей zip разрешение метки времени — 2 с
|
||||
ZIP_TIME_RESOLUTION_SECONDS = 2.1
|
||||
LONG_TEXT_LENGTH = 400
|
||||
BIG_ROW = 70000
|
||||
|
||||
|
||||
def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None:
|
||||
"""Повторный запуск (позже, чем разрешение меток времени) даёт те же байты для каждого файла набора."""
|
||||
first = generate_all(tmp_path / "first")
|
||||
time.sleep(ZIP_TIME_RESOLUTION_SECONDS)
|
||||
second = generate_all(tmp_path / "second")
|
||||
|
||||
assert [path.name for path in first] == [path.name for path in second]
|
||||
for left, right in zip(first, second, strict=True):
|
||||
assert left.read_bytes() == right.read_bytes(), left.name
|
||||
|
||||
|
||||
def test_generated_set_is_s01_to_s13_with_one_xlsb(tmp_path: Path) -> None:
|
||||
names = [path.name for path in generate_all(tmp_path)]
|
||||
|
||||
assert names == list(FILE_NAMES)
|
||||
assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names)
|
||||
assert [name[1:3] for name in names] == [f"{number:02d}" for number in range(1, len(names) + 1)]
|
||||
assert [name for name in names if name.endswith(".xlsb")] == ["s13_no_outline_indent.xlsb"]
|
||||
assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names)
|
||||
|
||||
|
||||
def test_xlsb_writer_round_trips_through_the_reader() -> None:
|
||||
"""Тексты (с ведущими пробелами и кириллицей), числа и несколько листов читаются `src.parser.readers`."""
|
||||
long_text = "щ" * LONG_TEXT_LENGTH # запись длиннее 127 байт: длина кодируется двумя байтами
|
||||
data = write_xlsb(
|
||||
{
|
||||
"Лист_1": {
|
||||
(1, 1): "Привет",
|
||||
(2, 3): Decimal("1234567.89"),
|
||||
(3, 1): " отступ",
|
||||
(4, 2): long_text,
|
||||
(BIG_ROW, 4): 7,
|
||||
},
|
||||
"Второй": {(1, 1): "Привет"},
|
||||
}
|
||||
)
|
||||
|
||||
sheets, warnings = read_book(data, "XLSB")
|
||||
|
||||
assert [sheet.name for sheet in sheets] == ["Лист_1", "Второй"]
|
||||
first = sheets[0]
|
||||
assert first.text_at(1, 1) == "Привет"
|
||||
assert first.cell(2, 3).value == Decimal("1234567.89")
|
||||
assert (first.text_at(3, 1), first.cell(3, 1).indent) == ("отступ", 4)
|
||||
assert first.text_at(4, 2) == long_text
|
||||
assert first.cell(BIG_ROW, 4).value == Decimal(7)
|
||||
assert first.n_rows == BIG_ROW
|
||||
assert sheets[1].text_at(1, 1) == "Привет"
|
||||
assert warnings == ["xlsb: outline level недоступен"]
|
||||
|
||||
|
||||
def test_xlsb_writer_is_deterministic() -> None:
|
||||
cells = {"Лист_1": {(1, 1): "а", (2, 2): Decimal("1.50")}}
|
||||
|
||||
assert write_xlsb(cells) == write_xlsb(cells)
|
||||
414
api/tests/synthetic/test_synthetic_files.py
Normal file
414
api/tests/synthetic/test_synthetic_files.py
Normal file
@ -0,0 +1,414 @@
|
||||
"""Сценарии спеки `osv-parsing` на синтетических файлах `../fixtures/osv/synthetic/` (Срез 2a, Task 11).
|
||||
|
||||
Ожидания выведены из сценариев спеки и из содержимого, которое генератор записывает в файл (суммы и тексты ниже
|
||||
перечислены в тесте явно), а не из текущего вывода парсера: файл — вход, оракулом он не служит. Без каталога fixtures
|
||||
тесты пропускаются (маркер `fixtures`).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from result_contract import assert_valid_result
|
||||
|
||||
from src.contract import FileType, OsvDocument, ResultStatus
|
||||
from src.contract.result import OsvRow
|
||||
from src.parser import ParseOutcome, get_parser
|
||||
|
||||
pytestmark = pytest.mark.fixtures
|
||||
|
||||
SYNTHETIC_DIR = Path("osv") / "synthetic"
|
||||
EXPECTED_NAMES = [
|
||||
"s01_variant2_header.xlsx",
|
||||
"s02_no_outline_indent.xlsx",
|
||||
"s03_no_outline_no_indent.xlsx",
|
||||
"s04_currency_quantity_rows.xlsx",
|
||||
"s05_text_numbers.xlsx",
|
||||
"s06_two_osv_and_other_sheet.xlsx",
|
||||
"s07_no_opening_collapsed_closing.xlsx",
|
||||
"s08_extra_kpp_column.xlsx",
|
||||
"s09_subaccount_names.xlsx",
|
||||
"s10_broken_sum.xlsx",
|
||||
"s11_header_repeat.xlsx",
|
||||
"s12_statement_and_osv.xlsx",
|
||||
"s13_no_outline_indent.xlsb",
|
||||
]
|
||||
XLSB_MISSING_REASON = "нет файла s13_no_outline_indent.xlsb (xlsb не получен; см. README fixtures)"
|
||||
VARIANT_2 = 2
|
||||
XLSB_WARNING = "xlsb: outline level недоступен"
|
||||
|
||||
ALFA = "ООО Альфа"
|
||||
BETA = "ООО Бета"
|
||||
GAMMA = "ООО Гамма"
|
||||
D = Decimal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Expect:
|
||||
"""Ожидания по файлу, общие для всех сценариев (шапка и итог разбора)."""
|
||||
|
||||
hierarchy: str
|
||||
year: int
|
||||
quarter: str
|
||||
cumulative: bool
|
||||
status: ResultStatus = ResultStatus.DONE
|
||||
documents: int = 1
|
||||
variant: int = 1
|
||||
data_type: str = "BU"
|
||||
|
||||
|
||||
EXPECTED: dict[str, Expect] = {
|
||||
# «Период: 1 квартал 2026 г.» — вариант 2, данные «сумма»
|
||||
"s01_variant2_header.xlsx": Expect("outline", 2026, "Q1", True, variant=2, data_type="SUM"),
|
||||
"s02_no_outline_indent.xlsx": Expect("indent", 2025, "Q2", False),
|
||||
# римский квартал «I кв.»
|
||||
"s03_no_outline_no_indent.xlsx": Expect("account_pattern", 2025, "Q1", True),
|
||||
"s04_currency_quantity_rows.xlsx": Expect("outline", 2025, "Q3", False),
|
||||
# «полугодие»
|
||||
"s05_text_numbers.xlsx": Expect("outline", 2025, "Q2", True),
|
||||
"s06_two_osv_and_other_sheet.xlsx": Expect("outline", 2025, "Q1", True, documents=2),
|
||||
# «9 месяцев»
|
||||
"s07_no_opening_collapsed_closing.xlsx": Expect("outline", 2025, "Q3", True),
|
||||
# диапазон дат «с 01.01.2025 по 31.03.2025»
|
||||
"s08_extra_kpp_column.xlsx": Expect("outline", 2025, "Q1", True),
|
||||
"s09_subaccount_names.xlsx": Expect("outline", 2025, "Q4", False),
|
||||
"s10_broken_sum.xlsx": Expect("outline", 2025, "Q1", True, status=ResultStatus.NEEDS_CHECK),
|
||||
# «12 месяцев»
|
||||
"s11_header_repeat.xlsx": Expect("outline", 2025, "Q4", True),
|
||||
# «январь - сентябрь», данные «НУ»
|
||||
"s12_statement_and_osv.xlsx": Expect("outline", 2025, "Q3", True, data_type="NU"),
|
||||
"s13_no_outline_indent.xlsb": Expect("indent", 2025, "Q2", False),
|
||||
}
|
||||
|
||||
|
||||
# --- Помощники ---
|
||||
|
||||
|
||||
def synthetic_path(fixtures_dir: Path, name: str) -> Path:
|
||||
return fixtures_dir / SYNTHETIC_DIR / name
|
||||
|
||||
|
||||
def parse_path(path: Path) -> ParseOutcome:
|
||||
return get_parser().parse(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper()))
|
||||
|
||||
|
||||
def parse_named(fixtures_dir: Path, name: str) -> ParseOutcome:
|
||||
path = synthetic_path(fixtures_dir, name)
|
||||
if not path.is_file():
|
||||
if name.endswith(".xlsb"):
|
||||
pytest.skip(XLSB_MISSING_REASON)
|
||||
pytest.fail(f"нет файла {name}: запустите генератор (см. README fixtures)")
|
||||
return parse_path(path)
|
||||
|
||||
|
||||
def documents(outcome: ParseOutcome) -> list[OsvDocument]:
|
||||
assert outcome.error is None, outcome.error
|
||||
docs = list(outcome.documents)
|
||||
assert all(isinstance(doc, OsvDocument) for doc in docs)
|
||||
return cast(list[OsvDocument], docs)
|
||||
|
||||
|
||||
def one_document(outcome: ParseOutcome) -> OsvDocument:
|
||||
(doc,) = documents(outcome)
|
||||
return doc
|
||||
|
||||
|
||||
def warnings_of(outcome: ParseOutcome) -> list[str]:
|
||||
assert outcome.metrics is not None
|
||||
assert outcome.metrics.warnings is not None
|
||||
return outcome.metrics.warnings
|
||||
|
||||
|
||||
def by_text(doc: OsvDocument) -> dict[str, OsvRow]:
|
||||
"""Строки по тексту (в синтетике тексты строк уникальны внутри листа)."""
|
||||
rows = {row.row_value: row for row in doc.rows}
|
||||
assert len(rows) == len(doc.rows), "тексты строк синтетики должны быть уникальны"
|
||||
return rows
|
||||
|
||||
|
||||
def rows_text(doc: OsvDocument) -> list[str]:
|
||||
return [row.row_value for row in doc.rows]
|
||||
|
||||
|
||||
def sums(row: OsvRow) -> tuple[Decimal | None, ...]:
|
||||
return (
|
||||
row.opening_debit,
|
||||
row.opening_credit,
|
||||
row.turnover_debit,
|
||||
row.turnover_credit,
|
||||
row.closing_debit,
|
||||
row.closing_credit,
|
||||
)
|
||||
|
||||
|
||||
def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | None, ...]]]:
|
||||
"""Дерево документа: текст, уровень, родитель, суммы — для сравнения двух файлов одной ведомости."""
|
||||
return [(row.row_value, row.level, row.parent_row_number, sums(row)) for row in doc.rows]
|
||||
|
||||
|
||||
# --- Состав набора и общие ожидания ---
|
||||
|
||||
|
||||
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
|
||||
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s13 (s13 — xlsb; допустима деградация: нет только его)."""
|
||||
present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file())
|
||||
assert present == [name for name in EXPECTED_NAMES if name in present]
|
||||
missing = [name for name in EXPECTED_NAMES if name not in present]
|
||||
assert missing in ([], [EXPECTED_NAMES[-1]]), f"нет файлов набора: {missing}"
|
||||
|
||||
|
||||
def test_every_synthetic_file_is_listed(synthetic_file: Path) -> None:
|
||||
"""Каждый файл каталога описан в таблице ожиданий (новый файл без ожиданий — ошибка набора)."""
|
||||
assert synthetic_file.name in EXPECTED, f"добавьте ожидания для {synthetic_file.name}"
|
||||
|
||||
|
||||
def test_header_status_and_hierarchy(synthetic_file: Path) -> None:
|
||||
"""Статус, число документов, стратегия иерархии и шапка из таблицы `EXPECTED`; результат валиден по контракту."""
|
||||
expect = EXPECTED[synthetic_file.name]
|
||||
|
||||
outcome = parse_path(synthetic_file)
|
||||
|
||||
assert outcome.status is expect.status, outcome.metrics
|
||||
docs = documents(outcome)
|
||||
assert len(docs) == expect.documents
|
||||
hierarchy_lines = [w for w in warnings_of(outcome) if f"hierarchy={expect.hierarchy}" in w]
|
||||
assert len(hierarchy_lines) == expect.documents, warnings_of(outcome)
|
||||
for doc in docs:
|
||||
assert doc.header.header_variant == expect.variant
|
||||
assert doc.header.period_year == expect.year
|
||||
assert doc.header.period_quarter == expect.quarter
|
||||
assert doc.header.is_cumulative is expect.cumulative
|
||||
assert doc.header.data_type == expect.data_type
|
||||
assert doc.header.organization_name == 'ООО "Тестовая организация"'
|
||||
assert doc.rows[-1].group_type == "TOTAL"
|
||||
if expect.status is ResultStatus.DONE:
|
||||
assert doc.checks is not None
|
||||
assert doc.checks.total_row_matches_root is True
|
||||
assert doc.checks.balance_identity_violations == []
|
||||
assert doc.checks.parent_sum_violations == []
|
||||
assert_valid_result(outcome)
|
||||
|
||||
|
||||
# --- Сценарии спеки ---
|
||||
|
||||
|
||||
def test_s01_variant2_header_and_subkonto_groups(fixtures_dir: Path) -> None:
|
||||
"""«Вариант 2»: `header_variant = 2`, `group_schema = [ACCOUNT, COUNTERPARTY, CONTRACT]`, `period_quarter = Q1`."""
|
||||
doc = one_document(parse_named(fixtures_dir, "s01_variant2_header.xlsx"))
|
||||
|
||||
assert doc.header.header_variant == VARIANT_2
|
||||
assert [group.type for group in doc.group_schema] == ["ACCOUNT", "COUNTERPARTY", "CONTRACT"]
|
||||
assert doc.header.period_quarter == "Q1"
|
||||
assert doc.header.period_text == "1 квартал 2026 г."
|
||||
rows = by_text(doc)
|
||||
contract = rows["Договор поставки № 15 от 10.01.2026"]
|
||||
assert contract.group_type == "CONTRACT"
|
||||
assert contract.parent_row_number == rows[ALFA].row_number
|
||||
assert rows[ALFA].group_type == "COUNTERPARTY"
|
||||
assert rows[ALFA].counterparty_inn == "7701234567"
|
||||
|
||||
|
||||
def test_s02_indent_levels_and_tree(fixtures_dir: Path) -> None:
|
||||
"""«Без outline level»: `hierarchy=indent`, уровни 0/1/2, родитель контрагента — субсчёт."""
|
||||
doc = one_document(parse_named(fixtures_dir, "s02_no_outline_indent.xlsx"))
|
||||
|
||||
rows = by_text(doc)
|
||||
assert [(text, rows[text].level) for text in ("60", "60.01", ALFA, "60.02", GAMMA, "Итого")] == [
|
||||
("60", 0),
|
||||
("60.01", 1),
|
||||
(ALFA, 2),
|
||||
("60.02", 1),
|
||||
(GAMMA, 2),
|
||||
("Итого", 0),
|
||||
]
|
||||
assert rows[ALFA].parent_row_number == rows["60.01"].row_number
|
||||
assert rows["60.01"].parent_row_number == rows["60"].row_number
|
||||
assert rows["60"].parent_row_number is None
|
||||
|
||||
|
||||
def test_s02_and_s03_describe_the_same_tree(fixtures_dir: Path) -> None:
|
||||
"""Одна ведомость с отступами (s02) и с кодами счетов и жирностью (s03): дерево и суммы совпадают."""
|
||||
indent = one_document(parse_named(fixtures_dir, "s02_no_outline_indent.xlsx"))
|
||||
pattern = one_document(parse_named(fixtures_dir, "s03_no_outline_no_indent.xlsx"))
|
||||
|
||||
assert tree(indent) == tree(pattern)
|
||||
|
||||
|
||||
def test_s03_account_pattern_levels(fixtures_dir: Path) -> None:
|
||||
"""«Без outline и отступов»: уровни по кодам счетов и жирности, контрольные суммы сходятся (в общем тесте)."""
|
||||
outcome = parse_named(fixtures_dir, "s03_no_outline_no_indent.xlsx")
|
||||
doc = one_document(outcome)
|
||||
|
||||
rows = by_text(doc)
|
||||
assert [rows[text].level for text in ("60", "60.01", ALFA, BETA, "Иванов Иван", "60.02", GAMMA)] == [
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
1,
|
||||
2,
|
||||
]
|
||||
assert rows[GAMMA].parent_row_number == rows["60.02"].row_number
|
||||
assert outcome.status is ResultStatus.DONE
|
||||
|
||||
|
||||
def test_s04_currency_and_quantity_rows(fixtures_dir: Path) -> None:
|
||||
"""«Строки Вал./Кол.»: USD и Вал. — CURRENCY, Кол. — QUANTITY; в проверку суммы детей не входят, статус `done`."""
|
||||
outcome = parse_named(fixtures_dir, "s04_currency_quantity_rows.xlsx")
|
||||
doc = one_document(outcome)
|
||||
|
||||
rows = by_text(doc)
|
||||
assert rows["USD"].data_type == "CURRENCY"
|
||||
assert rows["Кол."].data_type == "QUANTITY"
|
||||
assert rows["Вал."].data_type == "CURRENCY"
|
||||
assert rows["USD"].parent_row_number == rows[ALFA].row_number
|
||||
assert rows["Вал."].parent_row_number == rows[BETA].row_number
|
||||
assert rows[ALFA].data_type == "BU"
|
||||
assert rows[ALFA].group_type == "COUNTERPARTY"
|
||||
# суммы показателей остаются в шести полях
|
||||
assert rows["Кол."].opening_debit == D("10.00")
|
||||
assert rows["USD"].opening_credit == D("2500.00")
|
||||
assert outcome.status is ResultStatus.DONE
|
||||
|
||||
|
||||
def test_s05_text_numbers(fixtures_dir: Path) -> None:
|
||||
"""«Текстовые числа»: «1 234 567,89» → 1234567.89, «(1 234,00)» → -1234.00, «—»/«–»/«-» → null."""
|
||||
doc = one_document(parse_named(fixtures_dir, "s05_text_numbers.xlsx"))
|
||||
|
||||
rows = by_text(doc)
|
||||
assert sums(rows[ALFA]) == (D("1234567.89"), None, D("500.00"), D("-1234.00"), D("1236301.89"), None)
|
||||
# неразрывные пробелы и «–»
|
||||
assert sums(rows[BETA]) == (D("15000.00"), None, D("90000.00"), D("40000.00"), D("65000.00"), None)
|
||||
# узкие неразрывные пробелы и «-»
|
||||
assert sums(rows[GAMMA]) == (D("120000.00"), None, D("30000.00"), D("100000.00"), D("50000.00"), None)
|
||||
|
||||
|
||||
def test_s06_two_osv_sheets_and_other_sheet(fixtures_dir: Path) -> None:
|
||||
"""«Два листа ОСВ и посторонний лист»: два документа в порядке листов, в warnings — пропуск листа «Справка»."""
|
||||
outcome = parse_named(fixtures_dir, "s06_two_osv_and_other_sheet.xlsx")
|
||||
|
||||
docs = documents(outcome)
|
||||
assert [doc.locator for doc in docs] == ["sheet:ОСВ 60", "sheet:ОСВ 62"]
|
||||
assert [doc.header.account_code_raw for doc in docs] == ["60", "62"]
|
||||
assert any("Справка" in warning and "skipped" in warning for warning in warnings_of(outcome))
|
||||
assert not any("ОСВ 60" in w and "skipped" in w or "ОСВ 62" in w and "skipped" in w for w in warnings_of(outcome))
|
||||
assert outcome.metrics is not None
|
||||
assert outcome.metrics.rows == sum(1 for doc in docs for row in doc.rows if row.group_type != "TOTAL")
|
||||
|
||||
|
||||
def test_s07_no_opening_and_collapsed_closing(fixtures_dir: Path) -> None:
|
||||
"""«Без начала и со свёрнутым сальдо»: `opening_* = null`, отрицательное свёрнутое — в `closing_credit`, warning."""
|
||||
outcome = parse_named(fixtures_dir, "s07_no_opening_collapsed_closing.xlsx")
|
||||
doc = one_document(outcome)
|
||||
|
||||
for row in doc.rows:
|
||||
assert row.opening_debit is None
|
||||
assert row.opening_credit is None
|
||||
rows = by_text(doc)
|
||||
assert (rows[ALFA].closing_debit, rows[ALFA].closing_credit) == (None, D("250000.50"))
|
||||
assert (rows[BETA].closing_debit, rows[BETA].closing_credit) == (D("50000.00"), None)
|
||||
assert (rows[GAMMA].closing_debit, rows[GAMMA].closing_credit) == (None, None)
|
||||
assert rows[ALFA].turnover_credit == D("350000.50")
|
||||
assert any("свёрнутое сальдо" in warning for warning in warnings_of(outcome))
|
||||
assert outcome.status is ResultStatus.DONE
|
||||
|
||||
|
||||
def test_s08_kpp_column_goes_to_extra(fixtures_dir: Path) -> None:
|
||||
"""«Неизвестная колонка внутри таблицы»: `extra["КПП"]` у контрагентов, суммы не сдвинуты, ИНН в своей колонке."""
|
||||
doc = one_document(parse_named(fixtures_dir, "s08_extra_kpp_column.xlsx"))
|
||||
|
||||
rows = by_text(doc)
|
||||
assert rows[ALFA].extra == {"КПП": "770101001"}
|
||||
assert rows[BETA].extra == {"КПП": "770701001"}
|
||||
assert rows["Иванов Иван"].extra is None
|
||||
assert rows["60"].extra is None
|
||||
assert rows[ALFA].counterparty_inn == "7701234567"
|
||||
assert sums(rows[ALFA]) == (None, D("250000.50"), D("180000.25"), D("420000.00"), None, D("490000.25"))
|
||||
assert doc.columns is not None
|
||||
assert doc.columns["inn"] == "E"
|
||||
assert (doc.columns["opening_debit"], doc.columns["closing_credit"]) == ("G", "L")
|
||||
|
||||
|
||||
def test_s09_subaccount_names_in_account_rows(fixtures_dir: Path) -> None:
|
||||
"""«Название субсчёта в строке счёта»: ACCOUNT, `account_code_raw` без названия, `row_value` — исходный текст."""
|
||||
doc = one_document(parse_named(fixtures_dir, "s09_subaccount_names.xlsx"))
|
||||
|
||||
rows = by_text(doc)
|
||||
named = rows["60.01, Расчеты с поставщиками и подрядчиками"]
|
||||
assert (named.group_type, named.account_code_raw) == ("ACCOUNT", "60.01")
|
||||
assert rows["60.02, Расчеты по авансам выданным"].account_code_raw == "60.02"
|
||||
# буквенный субсчёт (R-16): счёт, а не контрагент; дети наследуют его код
|
||||
letter = rows["60.АВ, Расчеты по авансам (буквенный субсчёт)"]
|
||||
assert (letter.group_type, letter.account_code_raw) == ("ACCOUNT", "60.АВ")
|
||||
assert rows["ООО Дельта"].account_code_raw == "60.АВ"
|
||||
assert rows["ООО Дельта"].group_type == "COUNTERPARTY"
|
||||
assert rows[ALFA].account_code_raw == "60.01"
|
||||
|
||||
|
||||
def test_s10_broken_sum_needs_check(fixtures_dir: Path) -> None:
|
||||
"""«Испорченная сумма»: `needs_check`, нарушение тождества — у контрагента, суммы родителя — у его субсчёта."""
|
||||
outcome = parse_named(fixtures_dir, "s10_broken_sum.xlsx")
|
||||
doc = one_document(outcome)
|
||||
|
||||
rows = by_text(doc)
|
||||
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||||
assert doc.checks is not None
|
||||
assert doc.checks.balance_identity_violations == [rows[BETA].row_number]
|
||||
assert doc.checks.parent_sum_violations == [rows["60.01"].row_number]
|
||||
assert doc.checks.total_row_matches_root is True
|
||||
assert rows[BETA].closing_debit == D("65001.00")
|
||||
texts = [w for w in warnings_of(outcome) if "§1.4" in w]
|
||||
assert any(str(rows[BETA].row_number) in w for w in texts)
|
||||
assert any(str(rows["60.01"].row_number) in w for w in texts)
|
||||
|
||||
|
||||
def test_s11_header_repeat_is_skipped(fixtures_dir: Path) -> None:
|
||||
"""«Повтор заголовка внутри данных» (печать по страницам): строки заголовка не данные, дерево не нарушено."""
|
||||
outcome = parse_named(fixtures_dir, "s11_header_repeat.xlsx")
|
||||
doc = one_document(outcome)
|
||||
|
||||
assert rows_text(doc) == [
|
||||
"60",
|
||||
"60.01",
|
||||
ALFA,
|
||||
BETA,
|
||||
"Иванов Иван",
|
||||
"60.02",
|
||||
GAMMA,
|
||||
"ООО Дельта",
|
||||
"ИП Сидоров",
|
||||
"Итого",
|
||||
]
|
||||
rows = by_text(doc)
|
||||
assert [rows[text].level for text in rows_text(doc)] == [0, 1, 2, 2, 2, 1, 2, 2, 2, 0]
|
||||
assert rows["60.02"].parent_row_number == rows["60"].row_number
|
||||
assert outcome.status is ResultStatus.DONE
|
||||
|
||||
|
||||
def test_s12_statement_sheet_is_skipped_with_warning(fixtures_dir: Path) -> None:
|
||||
"""«Лист ББ + лист ОСВ»: один документ, лист формы пропущен с предупреждением о подсрезе 2b."""
|
||||
outcome = parse_named(fixtures_dir, "s12_statement_and_osv.xlsx")
|
||||
|
||||
(doc,) = documents(outcome)
|
||||
assert doc.locator == "sheet:ОСВ 60"
|
||||
assert any("Бухгалтерский баланс" in w and "skipped: statement" in w for w in warnings_of(outcome))
|
||||
assert outcome.status is ResultStatus.DONE
|
||||
|
||||
|
||||
def test_s13_xlsb_equals_s02_except_locator_and_warnings(fixtures_dir: Path) -> None:
|
||||
"""«XLSB»: результат совпадает с xlsx-оригиналом (s02), кроме `locator` и предупреждения об outline."""
|
||||
xlsb = parse_named(fixtures_dir, "s13_no_outline_indent.xlsb")
|
||||
xlsx = parse_named(fixtures_dir, "s02_no_outline_indent.xlsx")
|
||||
|
||||
xlsb_doc, xlsx_doc = one_document(xlsb), one_document(xlsx)
|
||||
assert xlsb_doc.model_copy(update={"locator": ""}) == xlsx_doc.model_copy(update={"locator": ""})
|
||||
assert xlsb.status is xlsx.status is ResultStatus.DONE
|
||||
assert XLSB_WARNING in warnings_of(xlsb)
|
||||
assert [w for w in warnings_of(xlsb) if w != XLSB_WARNING] == warnings_of(xlsx)
|
||||
assert any("hierarchy=indent" in w for w in warnings_of(xlsb))
|
||||
assert xlsb.metrics is not None
|
||||
assert xlsx.metrics is not None
|
||||
assert xlsb.metrics.rows == xlsx.metrics.rows
|
||||
191
api/tests/synthetic/xlsb_writer.py
Normal file
191
api/tests/synthetic/xlsb_writer.py
Normal file
@ -0,0 +1,191 @@
|
||||
"""Минимальный писатель XLSB (BIFF12) для тестов: значения ячеек и имена листов, без стилей и объединений.
|
||||
|
||||
Это НЕ файл Excel: в книге нет `styles.bin`, свойств документа и прочего, что пишет Excel. Структура — ровно то, что
|
||||
нужно читателю `pyxlsb` (его использует `src.parser.readers.xlsb`): `[Content_Types].xml`, `_rels/.rels`,
|
||||
`xl/workbook.bin` (+ `.rels`), `xl/sharedStrings.bin`, `xl/worksheets/sheetN.bin`. Строки лежат в таблице общих
|
||||
строк (`BrtCellIsst`), числа — `BrtCellReal` (double). Запись детерминирована: порядок частей фиксирован, метки времени
|
||||
в zip постоянны.
|
||||
|
||||
Назначение — получить `s13_no_outline_indent.xlsb` без Excel/LibreOffice (ruling R-9); совместимость с Excel не
|
||||
проверялась. Реальный xlsb, сохранённый Excel, остаётся нерешённым вопросом доверия к читателю (см. README fixtures).
|
||||
"""
|
||||
|
||||
import io
|
||||
import struct
|
||||
import zipfile
|
||||
from collections.abc import Mapping, Sequence
|
||||
from decimal import Decimal
|
||||
|
||||
__all__ = ["write_xlsb"]
|
||||
|
||||
# Метка времени всех частей архива постоянна: файл байт в байт воспроизводим
|
||||
ZIP_DATE_TIME = (2026, 1, 1, 0, 0, 0)
|
||||
|
||||
# Идентификаторы записей BIFF12 (MS-XLSB 2.3)
|
||||
_ROW_HDR = 0x0000
|
||||
_CELL_REAL = 0x0005
|
||||
_CELL_ISST = 0x0007
|
||||
_SST_ITEM = 0x0013
|
||||
_BEGIN_BOOK = 0x0183
|
||||
_END_BOOK = 0x0184
|
||||
_BEGIN_SHEET = 0x0181
|
||||
_END_SHEET = 0x0182
|
||||
_BEGIN_BUNDLE_SHS = 0x018F
|
||||
_END_BUNDLE_SHS = 0x0190
|
||||
_BUNDLE_SH = 0x019C
|
||||
_BEGIN_SHEET_DATA = 0x0191
|
||||
_END_SHEET_DATA = 0x0192
|
||||
_DIMENSION = 0x0194
|
||||
_BEGIN_SST = 0x019F
|
||||
_END_SST = 0x01A0
|
||||
|
||||
_ID_SINGLE_BYTE_LIMIT = 0x80
|
||||
_LEN_CONTINUATION = 0x80
|
||||
_LEN_PAYLOAD_MASK = 0x7F
|
||||
_LEN_BITS = 7
|
||||
_ROW_HEIGHT = 300 # 15 pt в двадцатых долях пункта
|
||||
|
||||
CellValue = str | int | Decimal | float
|
||||
SheetCells = Mapping[tuple[int, int], CellValue] # (строка, колонка) 1-based -> значение
|
||||
|
||||
_CONTENT_TYPES = (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
|
||||
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||
'<Override PartName="/xl/workbook.bin" ContentType="application/vnd.ms-excel.sheet.binary.macroEnabled.main"/>'
|
||||
"{sheets}"
|
||||
'<Override PartName="/xl/sharedStrings.bin" '
|
||||
'ContentType="application/vnd.ms-excel.sharedStrings"/>'
|
||||
"</Types>"
|
||||
)
|
||||
_SHEET_OVERRIDE = '<Override PartName="/xl/worksheets/sheet{n}.bin" ContentType="application/vnd.ms-excel.worksheet"/>'
|
||||
_ROOT_RELS = (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
'<Relationship Id="rId1" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" '
|
||||
'Target="xl/workbook.bin"/>'
|
||||
"</Relationships>"
|
||||
)
|
||||
_WORKBOOK_RELS = (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
|
||||
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||
"{sheets}"
|
||||
'<Relationship Id="rId{strings}" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" '
|
||||
'Target="sharedStrings.bin"/>'
|
||||
"</Relationships>"
|
||||
)
|
||||
_SHEET_REL = (
|
||||
'<Relationship Id="rId{n}" '
|
||||
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" '
|
||||
'Target="worksheets/sheet{n}.bin"/>'
|
||||
)
|
||||
|
||||
|
||||
def _record(record_id: int, payload: bytes = b"") -> bytes:
|
||||
"""Запись BIFF12: идентификатор (1–2 байта), длина (7-битные группы), данные."""
|
||||
head = bytes([record_id]) if record_id < _ID_SINGLE_BYTE_LIMIT else struct.pack("<H", record_id)
|
||||
size = len(payload)
|
||||
length = bytearray()
|
||||
while True:
|
||||
chunk = size & _LEN_PAYLOAD_MASK
|
||||
size >>= _LEN_BITS
|
||||
length.append(chunk | (_LEN_CONTINUATION if size else 0))
|
||||
if not size:
|
||||
break
|
||||
return head + bytes(length) + payload
|
||||
|
||||
|
||||
def _wide_string(text: str) -> bytes:
|
||||
"""XLWideString: число символов (uint32) и UTF-16LE."""
|
||||
return struct.pack("<I", len(text)) + text.encode("utf-16-le")
|
||||
|
||||
|
||||
def _shared_strings(strings: Sequence[str], total: int) -> bytes:
|
||||
body = [_record(_BEGIN_SST, struct.pack("<II", total, len(strings)))]
|
||||
body += [_record(_SST_ITEM, b"\x00" + _wide_string(text)) for text in strings]
|
||||
body.append(_record(_END_SST))
|
||||
return b"".join(body)
|
||||
|
||||
|
||||
def _workbook(names: Sequence[str]) -> bytes:
|
||||
body = [_record(_BEGIN_BOOK), _record(_BEGIN_BUNDLE_SHS)]
|
||||
for number, name in enumerate(names, start=1):
|
||||
# hsState (видимый), iTabID, strRelID, strName
|
||||
body.append(
|
||||
_record(_BUNDLE_SH, struct.pack("<II", 0, number) + _wide_string(f"rId{number}") + _wide_string(name))
|
||||
)
|
||||
body += [_record(_END_BUNDLE_SHS), _record(_END_BOOK)]
|
||||
return b"".join(body)
|
||||
|
||||
|
||||
def _number(value: int | Decimal | float) -> float:
|
||||
return float(value)
|
||||
|
||||
|
||||
def _worksheet(cells: SheetCells, string_ids: dict[str, int]) -> bytes:
|
||||
rows: dict[int, dict[int, CellValue]] = {}
|
||||
for (row, col), value in cells.items():
|
||||
rows.setdefault(row, {})[col] = value
|
||||
last_row = max(rows, default=1)
|
||||
last_col = max((col for row in rows.values() for col in row), default=1)
|
||||
body = [
|
||||
_record(_BEGIN_SHEET),
|
||||
# 0-based границы: строки, колонки
|
||||
_record(_DIMENSION, struct.pack("<IIII", 0, last_row - 1, 0, last_col - 1)),
|
||||
_record(_BEGIN_SHEET_DATA),
|
||||
]
|
||||
for row in sorted(rows):
|
||||
# rw, ixfe, miyRw, флаги, число диапазонов колонок
|
||||
body.append(_record(_ROW_HDR, struct.pack("<IIHBBI", row - 1, 0, _ROW_HEIGHT, 0, 0, 0)))
|
||||
for col in sorted(rows[row]):
|
||||
value = rows[row][col]
|
||||
head = struct.pack("<II", col - 1, 0) # колонка, стиль
|
||||
if isinstance(value, str):
|
||||
body.append(_record(_CELL_ISST, head + struct.pack("<I", string_ids[value])))
|
||||
else:
|
||||
body.append(_record(_CELL_REAL, head + struct.pack("<d", _number(value))))
|
||||
body += [_record(_END_SHEET_DATA), _record(_END_SHEET)]
|
||||
return b"".join(body)
|
||||
|
||||
|
||||
def write_xlsb(sheets: Mapping[str, SheetCells]) -> bytes:
|
||||
"""Книга XLSB: имя листа → ячейки `{(строка, колонка): значение}` (1-based; текст или число)."""
|
||||
names = list(sheets)
|
||||
strings: list[str] = []
|
||||
string_ids: dict[str, int] = {}
|
||||
total = 0
|
||||
for cells in sheets.values():
|
||||
for _, value in sorted(cells.items()):
|
||||
if isinstance(value, str):
|
||||
total += 1
|
||||
if value not in string_ids:
|
||||
string_ids[value] = len(strings)
|
||||
strings.append(value)
|
||||
parts: list[tuple[str, bytes]] = [
|
||||
(
|
||||
"[Content_Types].xml",
|
||||
_CONTENT_TYPES.format(
|
||||
sheets="".join(_SHEET_OVERRIDE.format(n=n) for n in range(1, len(names) + 1))
|
||||
).encode(),
|
||||
),
|
||||
("_rels/.rels", _ROOT_RELS.encode()),
|
||||
(
|
||||
"xl/_rels/workbook.bin.rels",
|
||||
_WORKBOOK_RELS.format(
|
||||
sheets="".join(_SHEET_REL.format(n=n) for n in range(1, len(names) + 1)), strings=len(names) + 1
|
||||
).encode(),
|
||||
),
|
||||
("xl/workbook.bin", _workbook(names)),
|
||||
("xl/sharedStrings.bin", _shared_strings(strings, total)),
|
||||
]
|
||||
parts += [(f"xl/worksheets/sheet{n}.bin", _worksheet(sheets[name], string_ids)) for n, name in enumerate(names, 1)]
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for name, data in parts:
|
||||
info = zipfile.ZipInfo(name, date_time=ZIP_DATE_TIME)
|
||||
info.compress_type = zipfile.ZIP_DEFLATED
|
||||
archive.writestr(info, data)
|
||||
return buffer.getvalue()
|
||||
@ -38,6 +38,7 @@ def test_synthetic_excluded_and_foreign_files_ignored(tmp_path: Path) -> None:
|
||||
(osv / "notes.txt").write_bytes(b"")
|
||||
(osv / "synthetic" / "s.xlsx").write_bytes(b"")
|
||||
(osv / "synthetic" / ".gitkeep").write_bytes(b"")
|
||||
(osv / "synthetic" / "README.md").write_bytes(b"")
|
||||
|
||||
assert osv_fixture_files(tmp_path) == [osv / "a.xls", osv / "c.xlsb", osv / "rb" / "b.xlsx"]
|
||||
assert synthetic_files(tmp_path) == [osv / "synthetic" / "s.xlsx"]
|
||||
|
||||
@ -397,9 +397,9 @@ def get_parser(settings: Settings | None = None) -> Parser
|
||||
| `s12_statement_and_osv.xlsx` | лист ББ + лист ОСВ → один документ + warning |
|
||||
| `s13_no_outline_indent.xlsb` | xlsb, сконвертирован вручную из `s02` (LibreOffice/Excel), закоммичен; `hierarchy=indent` |
|
||||
|
||||
- [ ] **Step 1:** тесты `test_synthetic_files.py` (параметр `synthetic_file`, маркер `fixtures`): таблица «файл → ожидания» (стратегия, статус, число документов, конкретные поля из сценариев спеки); для `s13` — результат равен `s02` кроме `locator`/warnings.
|
||||
- [ ] **Step 2:** генератор; `python -m tests.synthetic.generate ../../fixtures/osv/synthetic` (из `api/`); проверить детерминированность (повторный запуск — файлы байт в байт). xlsb: конвертировать `s02` вручную; если инструмента на машине нет — записать в README fixtures и в ledger как `deferred` с инструкцией, тест `s13` помечается `skip` с причиной «нет файла» (без провала плана).
|
||||
- [ ] **Step 3:** коммит в fixtures: `osv/synthetic: 12 синтетических ОСВ и xlsb (Срез 2a), README`; коммит здесь: `test: генератор синтетики и сценарии спеки на синтетических файлах`.
|
||||
- [x] **Step 1:** тесты `test_synthetic_files.py` (параметр `synthetic_file`, маркер `fixtures`): таблица «файл → ожидания» (стратегия, статус, число документов, конкретные поля из сценариев спеки); для `s13` — результат равен `s02` кроме `locator`/warnings.
|
||||
- [x] **Step 2:** генератор; `python -m tests.synthetic.generate ../../fixtures/osv/synthetic` (из `api/`); проверить детерминированность (повторный запуск — файлы байт в байт). xlsb: конвертировать `s02` вручную; если инструмента на машине нет — записать в README fixtures и в ledger как `deferred` с инструкцией, тест `s13` помечается `skip` с причиной «нет файла» (без провала плана).
|
||||
- [x] **Step 3:** коммит в fixtures: `osv/synthetic: 12 синтетических ОСВ и xlsb (Срез 2a), README`; коммит здесь: `test: генератор синтетики и сценарии спеки на синтетических файлах`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -54,7 +54,7 @@
|
||||
|
||||
## 4. Синтетика, golden, линтер
|
||||
|
||||
- [ ] 4.1 Генератор синтетики `tests/synthetic/generate.py` (детерминированный openpyxl) → `../fixtures/osv/synthetic/`: вариант 2
|
||||
- [x] 4.1 Генератор синтетики `tests/synthetic/generate.py` (детерминированный openpyxl) → `../fixtures/osv/synthetic/`: вариант 2
|
||||
шапки; без outline (отступы); без outline и отступов; Вал./Кол.; текстовые числа и скобки; два листа ОСВ + посторонний;
|
||||
без «начала» + свёрнутое сальдо; КПП-колонка; название субсчёта в строке; испорченная сумма; плюс один xlsb, сконвертированный
|
||||
вручную из синтетического xlsx и закоммиченный с пометкой в README fixtures. Тесты сценариев спеки на этих файлах.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user