From e1559c9770914f759ac1782427e306c076a09390 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 15:04:50 +0400 Subject: [PATCH] =?UTF-8?q?test:=20=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D1=80=20=D1=81=D0=B8=D0=BD=D1=82=D0=B5=D1=82=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B8=20=D1=81=D1=86=D0=B5=D0=BD=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B8=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B8=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=81=D0=B8=D0=BD=D1=82=D0=B5=D1=82=D0=B8=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D1=85=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/tests/fixtures_helpers.py | 9 +- api/tests/synthetic/__init__.py | 1 + api/tests/synthetic/generate.py | 600 ++++++++++++++++++ api/tests/synthetic/test_generate.py | 72 +++ api/tests/synthetic/test_synthetic_files.py | 414 ++++++++++++ api/tests/synthetic/xlsb_writer.py | 191 ++++++ api/tests/unit/test_fixtures_helpers.py | 1 + .../plans/2026-10-05-slice-2a-osv-parser.md | 6 +- openspec/changes/slice-2a-osv-parser/tasks.md | 2 +- 9 files changed, 1290 insertions(+), 6 deletions(-) create mode 100644 api/tests/synthetic/__init__.py create mode 100644 api/tests/synthetic/generate.py create mode 100644 api/tests/synthetic/test_generate.py create mode 100644 api/tests/synthetic/test_synthetic_files.py create mode 100644 api/tests/synthetic/xlsb_writer.py diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py index c044f20..31e455d 100644 --- a/api/tests/fixtures_helpers.py +++ b/api/tests/fixtures_helpers.py @@ -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 + ) diff --git a/api/tests/synthetic/__init__.py b/api/tests/synthetic/__init__.py new file mode 100644 index 0000000..56637b7 --- /dev/null +++ b/api/tests/synthetic/__init__.py @@ -0,0 +1 @@ +"""Синтетические ОСВ для форматов, которых нет в реальных файлах: генератор и тесты сценариев спеки.""" diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py new file mode 100644 index 0000000..4a02131 --- /dev/null +++ b/api/tests/synthetic/generate.py @@ -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"(]*>)[^<]*()") +_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()) diff --git a/api/tests/synthetic/test_generate.py b/api/tests/synthetic/test_generate.py new file mode 100644 index 0000000..a9a0cfc --- /dev/null +++ b/api/tests/synthetic/test_generate.py @@ -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) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py new file mode 100644 index 0000000..8ad6850 --- /dev/null +++ b/api/tests/synthetic/test_synthetic_files.py @@ -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 diff --git a/api/tests/synthetic/xlsb_writer.py b/api/tests/synthetic/xlsb_writer.py new file mode 100644 index 0000000..7f4e4c5 --- /dev/null +++ b/api/tests/synthetic/xlsb_writer.py @@ -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 = ( + '\n' + '' + '' + '' + '' + "{sheets}" + '' + "" +) +_SHEET_OVERRIDE = '' +_ROOT_RELS = ( + '\n' + '' + '' + "" +) +_WORKBOOK_RELS = ( + '\n' + '' + "{sheets}" + '' + "" +) +_SHEET_REL = ( + '' +) + + +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(">= _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(" bytes: + body = [_record(_BEGIN_SST, struct.pack(" 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(" 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(" 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() diff --git a/api/tests/unit/test_fixtures_helpers.py b/api/tests/unit/test_fixtures_helpers.py index 1614842..792777e 100644 --- a/api/tests/unit/test_fixtures_helpers.py +++ b/api/tests/unit/test_fixtures_helpers.py @@ -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"] diff --git a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md index 5e57e2b..c190b4d 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md +++ b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md @@ -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: генератор синтетики и сценарии спеки на синтетических файлах`. --- diff --git a/openspec/changes/slice-2a-osv-parser/tasks.md b/openspec/changes/slice-2a-osv-parser/tasks.md index e987753..09c3a0c 100644 --- a/openspec/changes/slice-2a-osv-parser/tasks.md +++ b/openspec/changes/slice-2a-osv-parser/tasks.md @@ -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. Тесты сценариев спеки на этих файлах.