fix(statements): исправлены данные st02, расширены тесты сценариев спеки, выпрямлены фикстуры

T13 round 1/5:
- st02: 1100=200, 1210=-50, 1200=-50 для выполнения всех проверок (1600 = 1100 + 1200, 1700 = 1300)
- Расширены тесты st01-st05: проверка колонок, типов документов, значений
- Исправлена фикстура _require_synthetic: используется statement_synthetic_skip_reason
- Добавлены константы для магических чисел
- Русские комментарии в коде

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:58:08 +04:00
parent d64ba71098
commit fad0c10412
4 changed files with 91 additions and 75 deletions

View File

@ -13,6 +13,7 @@ from fixtures_helpers import (
resolve_fixtures_dir,
statement_files,
statement_synthetic_files,
statement_synthetic_skip_reason,
synthetic_files,
synthetic_skip_reason,
)
@ -61,16 +62,6 @@ def minimal_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Callabl
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")
def synthetic_dir(fixtures_dir: Path) -> Path:
"""`fixtures/osv/synthetic`; тест пропускается с причиной, если синтетики в каталоге нет."""

View File

@ -100,3 +100,13 @@ def statement_synthetic_files(root: Path) -> list[Path]:
if not directory.is_dir():
return []
return sorted(path for path in directory.iterdir() if _is_workbook(path, directory))
def statement_synthetic_skip_reason() -> str | None:
"""Причина пропуска: каталог fixtures есть, но `statements-xlsx/synthetic` отсутствует или пуст.
`None` — файлы есть или нет самих fixtures (тогда причину даёт `fixtures_skip_reason`)."""
root = resolve_fixtures_dir()
if not (root / STATEMENTS_DIR).is_dir() or statement_synthetic_files(root):
return None
synth_path = root / STATEMENTS_DIR / _SYNTHETIC
return f"нет синтетики форм: {synth_path} отсутствует или пуст (сгенерируйте: см. README fixtures)"

View File

@ -93,13 +93,17 @@ def _st02() -> list[SheetSpec]:
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
# Приспособить значения чтобы все проверки соотношений прошли: 1210 = -50, 1200 = 1210 = -50,
# 1100 = 200, 1600 = 1100 + 1200 = 150, 1700 = 1300 = 150
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)")
elif code == "1100":
sheet.put(index, 3, 200)
sheet.put(index, 4, 200)
else:
sheet.put(index, 3, current)
sheet.put(index, 4, previous)

View File

@ -11,33 +11,25 @@ from typing import cast
import pytest
from result_contract import assert_valid_result
from src.contract import FileType, ResultStatus, StatementDocument
from src.contract import FileType, OsvDocument, ResultStatus, StatementDocument
from src.contract.result import StatementLine
from src.parser import ParseOutcome, get_parser
from tests.fixtures_helpers import statement_synthetic_skip_reason
from tests.synthetic.generate_statements import FILE_NAMES
pytestmark = pytest.mark.fixtures
SYNTHETIC_DIR = Path("statements-xlsx") / "synthetic"
EXPECTED_STATEMENT_COUNT_TWO = 2
EXPECTED_COLUMN_COUNT = 2
@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 _require_synthetic() -> None:
"""T13.4: fixtures есть, а `statements-xlsx/synthetic` нет/пуст — skip, а не падение."""
reason = statement_synthetic_skip_reason()
if reason is not None:
pytest.skip(reason)
def synthetic_path(fixtures_dir: Path, name: str) -> Path:
@ -61,6 +53,11 @@ def statement_documents(outcome: ParseOutcome) -> list[StatementDocument]:
return [doc for doc in outcome.documents if isinstance(doc, StatementDocument)]
def osv_documents(outcome: ParseOutcome) -> list[OsvDocument]:
"""Документы ОСВ результата."""
return [doc for doc in outcome.documents if isinstance(doc, OsvDocument)]
def one_statement(outcome: ParseOutcome) -> StatementDocument:
(doc,) = statement_documents(outcome)
return doc
@ -78,7 +75,7 @@ def by_code(doc: StatementDocument) -> dict[str, StatementLine]:
return lines
def get_line_value(line: StatementLine, column_index: int = 0) -> D | None:
def get_line_value(line: StatementLine, column_index: int = 0) -> Decimal | None:
"""Получить значение из строки по индексу колонки."""
for value in line.values:
if value.column_index == column_index:
@ -90,107 +87,121 @@ def get_line_value(line: StatementLine, column_index: int = 0) -> D | None:
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
"""В `fixtures/statements-xlsx/synthetic/` лежат файлы набора st01…st05."""
"""В `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
assert present == list(FILE_NAMES)
def test_every_synthetic_file_is_listed(statement_synthetic_file: Path) -> None:
"""Каждый файл каталога — один из ожидаемых."""
assert statement_synthetic_file.name in EXPECTED_NAMES
"""Каждый файл каталога соответствует одному из ожидаемых."""
assert statement_synthetic_file.name in FILE_NAMES
# --- Сценарии спеки ---
def test_st01_both_forms_one_sheet(fixtures_dir: Path) -> None:
"""st01: ББ и ОФР на одном листе; два документа с одним `locator`."""
"""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)}"
)
assert len(stmt_docs) == EXPECTED_STATEMENT_COUNT_TWO
# Оба документа имеют один locator
locators = {doc.locator for doc in stmt_docs}
assert len(locators) == EXPECTED_LOCATORS_COUNT, (
f"ожидается {EXPECTED_LOCATORS_COUNT} уникальный locator, получено {len(locators)}"
)
assert len(locators) == 1
# Проверяем типы документов
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}"
# Проверяем разделение кодов: 1xxx в BALANCE, 2xxx в INCOME
balance_codes = set()
income_codes = set()
for doc in stmt_docs:
codes = {line.line_code for line in doc.lines if line.line_code}
if doc.document_type == "BALANCE":
balance_codes.update(codes)
elif doc.document_type == "INCOME":
income_codes.update(codes)
# Проверяем что коды не пересекаются
assert not (balance_codes & income_codes), f"Пересечение кодов: {balance_codes & income_codes}"
# Проверяем что есть коды обоих типов
assert any(code.startswith("1") for code in balance_codes), "Должны быть коды 1xxx в BALANCE"
assert any(code.startswith("2") for code in income_codes), "Должны быть коды 2xxx в INCOME"
# Статус done
assert outcome.status is ResultStatus.DONE
def test_st02_csv_like_format(fixtures_dir: Path) -> None:
"""st02: CSV-подобный лист; организация и ИНН найдены, units None с warning."""
"""st02: CSV-подобный лист; организация/ИНН найдены, units None, колонки [2024-12-31, 2023-12-31], 1210=-50."""
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
# Проверяем units = None
assert doc.header.units is None
# Проверяем колонки: две даты
assert len(doc.columns) == EXPECTED_COLUMN_COUNT
assert all(col.period_date is not None for col in doc.columns)
# Проверяем код 1210 = -50
lines_by_code = by_code(doc)
if "1210" in lines_by_code:
line = lines_by_code["1210"]
col_0_value = get_line_value(line, 0)
assert col_0_value == Decimal("-50"), f"1210 в колонке 0 должна быть -50, получено {col_0_value}"
# Проверяем warning о единицах
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, "ожидаются строки данных"
# Примечание: warning о "Изменение, %" может быть не выдан из-за особенности двухстрочного заголовка (см. отчёт)
def test_st03_no_code_column(fixtures_dir: Path) -> None:
"""st03: форма без колонки кодов; needs_check, warning, lines = []."""
"""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
# Нет найденных строк
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}"
)
EXPECTED_LINES_COUNT = 0
def test_st04_mixed_book_osv_and_statement(fixtures_dir: Path) -> None:
"""st04: книга с листом ОСВ и листом баланса; OsvDocument + StatementDocument, статус done."""
"""st04: книга с листом ОСВ и листом баланса; OsvDocument + StatementDocument, порядок правильный."""
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)}"
)
# Проверяем количество документов
assert len(outcome.documents) == EXPECTED_STATEMENT_COUNT_TWO
# Проверяем что есть также OsvDocument (проверяя общее количество документов)
assert len(outcome.documents) == EXPECTED_DOCS_COUNT, (
f"ожидается {EXPECTED_DOCS_COUNT} документа всего (OSV + Statement), получено {len(outcome.documents)}"
)
# Проверяем типы и порядок
assert isinstance(outcome.documents[0], OsvDocument), "Первый документ должен быть OsvDocument"
assert isinstance(outcome.documents[1], StatementDocument), "Второй документ должен быть StatementDocument"
assert outcome.documents[1].document_type == "BALANCE"
# Статус done
assert outcome.status is ResultStatus.DONE
def test_st05_continuation_page(fixtures_dir: Path) -> None:
"""st05: одинокая страница 2; needs_check, warning, содержит коды."""
"""st05: одинокая страница 2; needs_check, warning о continuation."""
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()
@ -199,13 +210,13 @@ def test_st05_continuation_page(fixtures_dir: Path) -> None:
# Проверяем что есть коды
codes = {line.line_code for line in doc.lines if line.line_code}
assert len(codes) > 0, "ожидаются коды, получены пустое множество"
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)