arm-tabular-parser/api/tests/synthetic/test_synthetic_files.py
Andrey Epifancev e1559c9770 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
2026-10-05 15:04:50 +04:00

415 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сценарии спеки `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