diff --git a/api/tests/conftest.py b/api/tests/conftest.py index 2344d5b..225ba37 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -11,6 +11,8 @@ from fixtures_helpers import ( osv_dkb_golden_files, osv_fixture_files, resolve_fixtures_dir, + statement_files, + statement_synthetic_files, synthetic_files, synthetic_skip_reason, ) @@ -59,6 +61,16 @@ 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`; тест пропускается с причиной, если синтетики в каталоге нет.""" @@ -78,13 +90,19 @@ def fixtures_dir() -> Path: def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: - """Параметры `osv_file` (реальные ОСВ), `dkb_file` (пакет ДКБ), `dkb_golden_file` (ДКБ до порога снимка) и - `synthetic_file` (синтетика); без каталога — один параметр со skip.""" + """Параметры `osv_file` (реальные ОСВ), `dkb_file` (пакет ДКБ), `dkb_golden_file` (ДКБ до порога снимка), + `synthetic_file` (синтетика ОСВ), `statement_file` (реальные формы) и `statement_synthetic_file` (синтетика форм); + без каталога — один параметр со skip.""" sources = { "osv_file": (osv_fixture_files, fixtures_skip_reason), "dkb_file": (osv_dkb_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()), + "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(): if argname not in metafunc.fixturenames: diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py index d991a4c..8558b10 100644 --- a/api/tests/fixtures_helpers.py +++ b/api/tests/fixtures_helpers.py @@ -5,6 +5,7 @@ from pathlib import Path FIXTURES_ENV = "ARM_KA_FIXTURES" OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"}) +STATEMENTS_DIR = "statements-xlsx" _SYNTHETIC = "synthetic" _OSV_DKB = "osv-dkb" # Golden-снимок делается для файла пакета ДКБ не больше 1 МиБ: у больших (Агрокомплекс, 9–14 МБ, ~50 тыс. строк) @@ -80,3 +81,22 @@ def 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_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)) diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 11893d3..14bea47 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -28,7 +28,7 @@ from openpyxl.styles import Alignment, Font 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) ZIP_DATE_TIME = (2026, 1, 1, 0, 0, 0) diff --git a/api/tests/synthetic/generate_statements.py b/api/tests/synthetic/generate_statements.py new file mode 100644 index 0000000..3bb0cf7 --- /dev/null +++ b/api/tests/synthetic/generate_statements.py @@ -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()) diff --git a/api/tests/synthetic/test_generate_statements.py b/api/tests/synthetic/test_generate_statements.py new file mode 100644 index 0000000..2e81f8d --- /dev/null +++ b/api/tests/synthetic/test_generate_statements.py @@ -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) diff --git a/api/tests/synthetic/test_statement_synthetic_files.py b/api/tests/synthetic/test_statement_synthetic_files.py new file mode 100644 index 0000000..c0d9642 --- /dev/null +++ b/api/tests/synthetic/test_statement_synthetic_files.py @@ -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)