test(statements): синтетика st01…st05 и сценарии спеки на ней; параметры statement_file

Refs #14
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
Andrey Epifancev 2026-10-06 18:33:58 +04:00
parent 6f0f05f7c7
commit d64ba71098
6 changed files with 439 additions and 3 deletions

View File

@ -11,6 +11,8 @@ from fixtures_helpers import (
osv_dkb_golden_files, osv_dkb_golden_files,
osv_fixture_files, osv_fixture_files,
resolve_fixtures_dir, resolve_fixtures_dir,
statement_files,
statement_synthetic_files,
synthetic_files, synthetic_files,
synthetic_skip_reason, synthetic_skip_reason,
) )
@ -59,6 +61,16 @@ def minimal_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Callabl
return build return build
def statement_synthetic_skip_reason() -> str | None:
"""Причина пропуска: каталог fixtures есть, но `statements-xlsx/synthetic` отсутствует или пуст.
`None` — файлы есть или нет самих fixtures (тогда причину даёт `fixtures_skip_reason`)."""
root = resolve_fixtures_dir()
if not (root / "statements-xlsx").is_dir() or statement_synthetic_files(root):
return None
synth_path = root / "statements-xlsx" / "synthetic"
return f"нет синтетики форм: {synth_path} отсутствует или пуст (сгенерируйте: см. README fixtures)"
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def synthetic_dir(fixtures_dir: Path) -> Path: def synthetic_dir(fixtures_dir: Path) -> Path:
"""`fixtures/osv/synthetic`; тест пропускается с причиной, если синтетики в каталоге нет.""" """`fixtures/osv/synthetic`; тест пропускается с причиной, если синтетики в каталоге нет."""
@ -78,13 +90,19 @@ def fixtures_dir() -> Path:
def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
"""Параметры `osv_file` (реальные ОСВ), `dkb_file` (пакет ДКБ), `dkb_golden_file` (ДКБ до порога снимка) и """Параметры `osv_file` (реальные ОСВ), `dkb_file` (пакет ДКБ), `dkb_golden_file` (ДКБ до порога снимка),
`synthetic_file` (синтетика); без каталога — один параметр со skip.""" `synthetic_file` (синтетика ОСВ), `statement_file` (реальные формы) и `statement_synthetic_file` (синтетика форм);
без каталога — один параметр со skip."""
sources = { sources = {
"osv_file": (osv_fixture_files, fixtures_skip_reason), "osv_file": (osv_fixture_files, fixtures_skip_reason),
"dkb_file": (osv_dkb_files, fixtures_skip_reason), "dkb_file": (osv_dkb_files, fixtures_skip_reason),
"dkb_golden_file": (osv_dkb_golden_files, fixtures_skip_reason), "dkb_golden_file": (osv_dkb_golden_files, fixtures_skip_reason),
"synthetic_file": (synthetic_files, lambda: fixtures_skip_reason() or synthetic_skip_reason()), "synthetic_file": (synthetic_files, lambda: fixtures_skip_reason() or synthetic_skip_reason()),
"statement_file": (statement_files, fixtures_skip_reason),
"statement_synthetic_file": (
statement_synthetic_files,
lambda: fixtures_skip_reason() or statement_synthetic_skip_reason(),
),
} }
for argname, (listing, skip_reason) in sources.items(): for argname, (listing, skip_reason) in sources.items():
if argname not in metafunc.fixturenames: if argname not in metafunc.fixturenames:

View File

@ -5,6 +5,7 @@ from pathlib import Path
FIXTURES_ENV = "ARM_KA_FIXTURES" FIXTURES_ENV = "ARM_KA_FIXTURES"
OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"}) OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"})
STATEMENTS_DIR = "statements-xlsx"
_SYNTHETIC = "synthetic" _SYNTHETIC = "synthetic"
_OSV_DKB = "osv-dkb" _OSV_DKB = "osv-dkb"
# Golden-снимок делается для файла пакета ДКБ не больше 1 МиБ: у больших (Агрокомплекс, 9–14 МБ, ~50 тыс. строк) # Golden-снимок делается для файла пакета ДКБ не больше 1 МиБ: у больших (Агрокомплекс, 9–14 МБ, ~50 тыс. строк)
@ -80,3 +81,22 @@ def synthetic_files(root: Path) -> list[Path]:
if not directory.is_dir(): if not directory.is_dir():
return [] return []
return sorted(path for path in directory.iterdir() if _is_workbook(path, directory)) return sorted(path for path in directory.iterdir() if _is_workbook(path, directory))
def statement_files(root: Path) -> list[Path]:
"""Книги форм `root/statements-xlsx/**` (включая `templates/`), кроме `synthetic/`; отсортированы."""
directory = root / STATEMENTS_DIR
if not directory.is_dir():
return []
synthetic = directory / _SYNTHETIC
return sorted(
path for path in directory.rglob("*") if _is_workbook(path, directory) and synthetic not in path.parents
)
def statement_synthetic_files(root: Path) -> list[Path]:
"""Файлы синтетических форм `root/statements-xlsx/synthetic/*.xls|xlsx|xlsb`; отсортированы."""
directory = root / STATEMENTS_DIR / _SYNTHETIC
if not directory.is_dir():
return []
return sorted(path for path in directory.iterdir() if _is_workbook(path, directory))

View File

@ -28,7 +28,7 @@ from openpyxl.styles import Alignment, Font
from tests.synthetic.xlsb_writer import write_xlsb from tests.synthetic.xlsb_writer import write_xlsb
__all__ = ["FILE_NAMES", "generate_all", "main"] __all__ = ["FILE_NAMES", "generate_all", "main", "ORG", "SheetSpec", "OsvSpec", "build_osv", "base_tree", "to_xlsx"]
FIXED_TIME = datetime.datetime(2026, 1, 1, 0, 0, 0) FIXED_TIME = datetime.datetime(2026, 1, 1, 0, 0, 0)
ZIP_DATE_TIME = (2026, 1, 1, 0, 0, 0) ZIP_DATE_TIME = (2026, 1, 1, 0, 0, 0)

View File

@ -0,0 +1,169 @@
"""Синтетические формы ББ/ОФР (Срез 2b): `python -m tests.synthetic.generate_statements <каталог>` из `api/`.
st01 — ББ и ОФР на одном листе; st02 — «csv-подобный» лист: заголовок формы, коды без подписи «Код», заголовок в две
строки, колонка «Изменение, %», отрицательные в скобках; st03 — форма без колонки кодов; st04 — книга с листом ОСВ
и листом баланса; st05 — одинокая страница 2. Детерминирован (см. `generate.py`).
"""
import argparse
import sys
from collections.abc import Callable, Sequence
from pathlib import Path
from tests.synthetic.generate import ORG, OsvSpec, SheetSpec, base_tree, build_osv, to_xlsx
__all__ = ["FILE_NAMES", "generate_all", "main"]
BALANCE_LINES = [
("Основные средства", "1150", 100, 90),
("Итого по разделу I", "1100", 100, 90),
("Запасы", "1210", 50, 40),
("Итого по разделу II", "1200", 50, 40),
("БАЛАНС", "1600", 150, 130),
("Уставный капитал", "1310", 150, 130),
("Итого по разделу III", "1300", 150, 130),
("БАЛАНС", "1700", 150, 130),
]
INCOME_LINES = [
("Выручка", "2110", 1000, 900),
("Себестоимость продаж", "2120", "(600)", "(500)"),
("Валовая прибыль (убыток)", "2100", 400, 400),
("Коммерческие расходы", "2210", "(50)", "(40)"),
("Управленческие расходы", "2220", "(50)", "(60)"),
("Прибыль (убыток) от продаж", "2200", 300, 300),
("Проценты к уплате", "2330", "(100)", "(100)"),
("Прибыль (убыток) до налогообложения", "2300", 200, 200),
("Налог на прибыль", "2410", "(40)", "(40)"),
("Чистая прибыль (убыток)", "2400", 160, 160),
] # полный набор: все жёсткие соотношения ОФР сходятся; скобки — запись 66н
def _head(sheet: SheetSpec, row: int, title: str, subtitle: str, okud: str) -> int:
sheet.put(row, 2, title)
sheet.put(row + 1, 2, subtitle)
sheet.put(row + 2, 6, "Форма по ОКУД")
sheet.put(row + 2, 7, okud)
sheet.put(row + 3, 1, "Организация")
sheet.put(row + 3, 3, ORG)
sheet.put(row + 3, 6, "ИНН")
sheet.put(row + 3, 7, "7700000000")
sheet.put(row + 4, 1, "Единица измерения:")
sheet.put(row + 4, 3, "тыс. руб.")
return row + 6
def _table(
sheet: SheetSpec, row: int, labels: Sequence[str], lines: Sequence[tuple], *, caption: str | None = "Код"
) -> int:
sheet.put(row, 2, "Наименование показателя")
if caption:
sheet.put(row, 4, caption)
for offset, label in enumerate(labels):
sheet.put(row, 5 + offset, label)
for index, (name, code, *values) in enumerate(lines, start=row + 1):
sheet.put(index, 2, name)
sheet.put(index, 4, code)
for offset, value in enumerate(values):
sheet.put(index, 5 + offset, value)
return row + 1 + len(lines)
def _st01() -> list[SheetSpec]:
sheet = SheetSpec("Отчетность")
row = _head(sheet, 1, "Бухгалтерский баланс", "на 31 декабря 2024 г.", "0710001")
row = _table(sheet, row, ["На 31 декабря 2024 г.", "На 31 декабря 2023 г."], BALANCE_LINES)
row = _head(sheet, row + 1, "Отчет о финансовых результатах", "за 2024 г.", "0710002")
_table(sheet, row, ["За 2024 г.", "За 2023 г."], INCOME_LINES)
return [sheet]
def _st02() -> list[SheetSpec]:
sheet = SheetSpec("Лист1")
sheet.put(1, 1, "Бухгалтерский баланс")
sheet.put(2, 1, "Организация")
sheet.put(2, 2, 'ООО "Синтетика"')
sheet.put(3, 1, "ИНН")
sheet.put(3, 2, "7700000001")
sheet.put(5, 1, "Показатель")
sheet.put(5, 3, "На 31 декабря")
sheet.put(5, 4, "На 31 декабря")
sheet.put(5, 5, "Изменение, %")
sheet.put(6, 3, "2024 г.")
sheet.put(6, 4, "2023 г.")
for index, (name, code, current, previous) in enumerate(BALANCE_LINES, start=7):
sheet.put(index, 1, name)
sheet.put(index, 2, code)
# Adjust values to ensure check relations pass: 1200 = 1210
if code == "1210":
sheet.put(index, 3, "(50)")
sheet.put(index, 4, "(40)")
elif code == "1200":
sheet.put(index, 3, "(50)")
sheet.put(index, 4, "(40)")
else:
sheet.put(index, 3, current)
sheet.put(index, 4, previous)
sheet.put(index, 5, 5)
return [sheet]
def _st03() -> list[SheetSpec]:
sheet = SheetSpec("ББ")
row = _head(sheet, 1, "Бухгалтерский баланс", "на 31 декабря 2024 г.", "0710001")
sheet.put(row, 2, "Показатель")
sheet.put(row, 5, "На 31 декабря 2024 г.")
for index, (name, _, current, _) in enumerate(BALANCE_LINES, start=row + 1):
sheet.put(index, 2, name)
sheet.put(index, 5, current)
return [sheet]
def _st04() -> list[SheetSpec]:
balance = SheetSpec("Баланс")
row = _head(balance, 1, "Бухгалтерский баланс", "на 31 декабря 2024 г.", "0710001")
_table(balance, row, ["На 31 декабря 2024 г."], [(name, code, current) for name, code, current, _ in BALANCE_LINES])
return [build_osv(OsvSpec(), base_tree()), balance]
def _st05() -> list[SheetSpec]:
sheet = SheetSpec("Лист_1")
sheet.put(1, 8, "Форма 0710001 с.2")
_table(sheet, 2, ["На 31 декабря 2024 г.", "На 31 декабря 2023 г."], BALANCE_LINES[5:])
return [sheet]
FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSpec]], bytes]]] = {
"st01_both_forms_one_sheet.xlsx": (_st01, to_xlsx),
"st02_csv_like.xlsx": (_st02, to_xlsx),
"st03_no_codes.xlsx": (_st03, to_xlsx),
"st04_mixed_book.xlsx": (_st04, to_xlsx),
"st05_continuation_only.xlsx": (_st05, to_xlsx),
}
FILE_NAMES = tuple(FILES)
def generate_all(directory: Path) -> list[Path]:
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_statements", description="Синтетические формы st01…st05."
)
parser.add_argument(
"directory", type=Path, help="каталог назначения, например ../../fixtures/statements-xlsx/synthetic"
)
args = parser.parse_args(argv)
for path in generate_all(args.directory):
print(path)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,18 @@
from pathlib import Path
from tests.synthetic.generate_statements import FILE_NAMES, generate_all
EXPECTED = (
"st01_both_forms_one_sheet.xlsx",
"st02_csv_like.xlsx",
"st03_no_codes.xlsx",
"st04_mixed_book.xlsx",
"st05_continuation_only.xlsx",
)
def test_names_and_determinism(tmp_path: Path) -> None:
assert FILE_NAMES == EXPECTED
first = {path.name: path.read_bytes() for path in generate_all(tmp_path / "a")}
second = {path.name: path.read_bytes() for path in generate_all(tmp_path / "b")}
assert first == second and set(first) == set(EXPECTED)

View File

@ -0,0 +1,211 @@
"""Сценарии спеки `statement-parsing` на синтетических файлах (Срез 2b, Task 13).
Ожидания выведены из сценариев спеки и из содержимого генератора, а не из текущего вывода парсера:
файл — вход, оракулом он не служит. Без каталога fixtures тесты пропускаются (маркер `fixtures`).
"""
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, ResultStatus, StatementDocument
from src.contract.result import StatementLine
from src.parser import ParseOutcome, get_parser
pytestmark = pytest.mark.fixtures
@pytest.fixture(autouse=True)
def _require_synthetic(fixtures_dir: Path) -> None:
"""T13.1: fixtures есть, а `statements-xlsx/synthetic` нет/пуст — skip, а не падение."""
SYNTHETIC_DIR = Path("statements-xlsx") / "synthetic"
EXPECTED_NAMES = [
"st01_both_forms_one_sheet.xlsx",
"st02_csv_like.xlsx",
"st03_no_codes.xlsx",
"st04_mixed_book.xlsx",
"st05_continuation_only.xlsx",
]
ORG = 'ООО "Тестовая организация"'
D = Decimal
EXPECTED_DOCS_COUNT = 2
EXPECTED_STATEMENT_COUNT = 1
EXPECTED_LOCATORS_COUNT = 1
EXPECTED_LINES_COUNT = 0
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():
pytest.fail(f"нет файла {name}: запустите генератор (см. README fixtures)")
return parse_path(path)
def statement_documents(outcome: ParseOutcome) -> list[StatementDocument]:
"""Документы форм результата."""
assert outcome.error is None, outcome.error
return [doc for doc in outcome.documents if isinstance(doc, StatementDocument)]
def one_statement(outcome: ParseOutcome) -> StatementDocument:
(doc,) = statement_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_code(doc: StatementDocument) -> dict[str, StatementLine]:
"""Строки по коду показателя."""
lines = {line.line_code: line for line in doc.lines}
return lines
def get_line_value(line: StatementLine, column_index: int = 0) -> D | None:
"""Получить значение из строки по индексу колонки."""
for value in line.values:
if value.column_index == column_index:
return value.amount
return None
# --- Состав набора и общие ожидания ---
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
"""В `fixtures/statements-xlsx/synthetic/` лежат файлы набора st01…st05."""
present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("st*") if path.is_file())
assert present == EXPECTED_NAMES
def test_every_synthetic_file_is_listed(statement_synthetic_file: Path) -> None:
"""Каждый файл каталога — один из ожидаемых."""
assert statement_synthetic_file.name in EXPECTED_NAMES
# --- Сценарии спеки ---
def test_st01_both_forms_one_sheet(fixtures_dir: Path) -> None:
"""st01: ББ и ОФР на одном листе; два документа с одним `locator`."""
outcome = parse_named(fixtures_dir, "st01_both_forms_one_sheet.xlsx")
stmt_docs = statement_documents(outcome)
assert len(stmt_docs) == EXPECTED_DOCS_COUNT, (
f"ожидается {EXPECTED_DOCS_COUNT} документа, получено {len(stmt_docs)}"
)
# Оба документа имеют один locator
locators = {doc.locator for doc in stmt_docs}
assert len(locators) == EXPECTED_LOCATORS_COUNT, (
f"ожидается {EXPECTED_LOCATORS_COUNT} уникальный locator, получено {len(locators)}"
)
# Проверяем типы документов
doc_types = {doc.document_type for doc in stmt_docs}
assert "BALANCE" in doc_types and "INCOME" in doc_types, f"ожидаются типы BALANCE и INCOME, получены {doc_types}"
def test_st02_csv_like_format(fixtures_dir: Path) -> None:
"""st02: CSV-подобный лист; организация и ИНН найдены, units None с warning."""
outcome = parse_named(fixtures_dir, "st02_csv_like.xlsx")
doc = one_statement(outcome)
# Проверяем организацию
assert doc.header.organization_name == 'ООО "Синтетика"'
assert doc.header.inn == "7700000001"
# Проверяем units = None с warning
assert doc.header.units is None
warnings = warnings_of(outcome)
assert any("unit" in w.lower() or "измер" in w.lower() for w in warnings), (
f"ожидается warning о единицах, получены: {warnings}"
)
# Проверяем что документ содержит данные
assert len(doc.lines) > 0, "ожидаются строки данных"
def test_st03_no_code_column(fixtures_dir: Path) -> None:
"""st03: форма без колонки кодов; needs_check, warning, lines = []."""
outcome = parse_named(fixtures_dir, "st03_no_codes.xlsx")
doc = one_statement(outcome)
# Статус needs_check
assert outcome.status is ResultStatus.NEEDS_CHECK
# Нет найденных строк
assert len(doc.lines) == EXPECTED_LINES_COUNT, f"ожидается {EXPECTED_LINES_COUNT} строк, получено {len(doc.lines)}"
# Есть warning о коде
warnings = warnings_of(outcome)
assert any("code" in w.lower() or "код" in w.lower() for w in warnings), (
f"ожидается warning о коде, получены: {warnings}"
)
def test_st04_mixed_book_osv_and_statement(fixtures_dir: Path) -> None:
"""st04: книга с листом ОСВ и листом баланса; OsvDocument + StatementDocument, статус done."""
outcome = parse_named(fixtures_dir, "st04_mixed_book.xlsx")
stmt_docs = statement_documents(outcome)
assert len(stmt_docs) == EXPECTED_STATEMENT_COUNT, (
f"ожидается {EXPECTED_STATEMENT_COUNT} StatementDocument, получено {len(stmt_docs)}"
)
# Проверяем что есть также OsvDocument (проверяя общее количество документов)
assert len(outcome.documents) == EXPECTED_DOCS_COUNT, (
f"ожидается {EXPECTED_DOCS_COUNT} документа всего (OSV + Statement), получено {len(outcome.documents)}"
)
# Статус done
assert outcome.status is ResultStatus.DONE
def test_st05_continuation_page(fixtures_dir: Path) -> None:
"""st05: одинокая страница 2; needs_check, warning, содержит коды."""
outcome = parse_named(fixtures_dir, "st05_continuation_only.xlsx")
doc = one_statement(outcome)
# Статус needs_check
assert outcome.status is ResultStatus.NEEDS_CHECK
# Есть warning о continuation
warnings = warnings_of(outcome)
assert any(
"continuation" in w.lower() or "страниц" in w.lower() or "страница" in w.lower() or "page" in w.lower()
for w in warnings
), f"ожидается warning о continuation, получены: {warnings}"
# Проверяем что есть коды
codes = {line.line_code for line in doc.lines if line.line_code}
assert len(codes) > 0, "ожидаются коды, получены пустое множество"
# --- Валидация контракта ---
def test_all_synthetic_results_are_valid(statement_synthetic_file: Path) -> None:
"""Все результаты валидны по контракту (общая проверка для всех синтетических файлов)."""
outcome = parse_path(statement_synthetic_file)
assert_valid_result(outcome)