From fad0c1041250d72e639ebd728e1dec5b63d49ed6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 18:58:08 +0400 Subject: [PATCH] =?UTF-8?q?fix(statements):=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B4=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20st02,=20=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D1=81?= =?UTF-8?q?=D1=86=D0=B5=D0=BD=D0=B0=D1=80=D0=B8=D0=B5=D0=B2=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B5=D0=BA=D0=B8,=20=D0=B2=D1=8B=D0=BF=D1=80=D1=8F=D0=BC?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=84=D0=B8=D0=BA=D1=81=D1=82=D1=83?= =?UTF-8?q?=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- api/tests/conftest.py | 11 +- api/tests/fixtures_helpers.py | 10 ++ api/tests/synthetic/generate_statements.py | 6 +- .../test_statement_synthetic_files.py | 139 ++++++++++-------- 4 files changed, 91 insertions(+), 75 deletions(-) diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 225ba37..b11a048 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -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`; тест пропускается с причиной, если синтетики в каталоге нет.""" diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py index 8558b10..9a768df 100644 --- a/api/tests/fixtures_helpers.py +++ b/api/tests/fixtures_helpers.py @@ -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)" diff --git a/api/tests/synthetic/generate_statements.py b/api/tests/synthetic/generate_statements.py index 3bb0cf7..26a13b8 100644 --- a/api/tests/synthetic/generate_statements.py +++ b/api/tests/synthetic/generate_statements.py @@ -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) diff --git a/api/tests/synthetic/test_statement_synthetic_files.py b/api/tests/synthetic/test_statement_synthetic_files.py index c0d9642..fc27a7c 100644 --- a/api/tests/synthetic/test_statement_synthetic_files.py +++ b/api/tests/synthetic/test_statement_synthetic_files.py @@ -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)