"""Сценарии спеки `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, FileWarning, OsvDocument, ResultStatus from src.contract.result import OsvRow from src.parser import ParseOutcome, get_parser from src.parser.readers.xls import NO_CODEPAGE_WARNING pytestmark = pytest.mark.fixtures @pytest.fixture(autouse=True) def _require_synthetic(synthetic_dir: Path) -> None: """T11.1: fixtures есть, а `osv/synthetic` нет/пуст (fixtures на main до слияния ветки) — skip, а не падение.""" SYNTHETIC_DIR = Path("osv") / "synthetic" INN_LAYOUT_FILES = ( "s20_inn_above_sibling_blank.xlsx", "s21_inn_above_sibling_sums.xlsx", "s22_inn_above_parent_blank.xlsx", "s23_inn_above_parent_sums.xlsx", "s24_inn_below_child_blank.xlsx", "s25_inn_below_child_sums.xlsx", "s26_inn_below_sibling_sums.xlsx", "s27_inn_below_sibling_blank.xlsx", ) INN_BAD_CHECKSUM_FILE = "s28_inn_bad_checksum.xlsx" XLS_NO_CODEPAGE_FILE = "s29_xls_no_codepage.xls" VAL_KOL_VO_FILE = "s30_val_kol_vo_materials.xlsx" ROWS_BELOW_TOTAL_FILE = "s31_rows_below_total.xlsx" TOTAL_WITH_SUFFIX_FILE = "s32_total_with_suffix.xlsx" # Плоская форма ОСВ 1С (#59) FLAT_FORM_FILES = ( "s40_flat_header_account.xlsx", "s41_flat_chart_of_accounts.xlsx", "s42_subaccounts_totals_reversed.xlsx", "s43_subkonto_unnamed_60.xlsx", "s44_subkonto_unnamed_10.xlsx", "s45_account_label_value_cells.xlsx", "s46_counterparty_code_blocks.xlsx", "s47_indent_hierarchy_code_sums.xlsx", ) # ОСВ других учётных систем (#81): контрольный набор — позитивы s48–s54 (на базе падают), негативы s55–s57 (вид листов # и результат не меняются) S48 = "s48_other_system_titles.xlsx" S49 = "s49_balance_and_turnover_by_side.xlsx" S50 = "s50_sap_in_out_glued_side.xlsx" S51 = "s51_untitled_navision.xlsx" S52 = "s52_mixed_book.xlsx" S53 = "s53_balance_by_side.xls" S54 = "s54_untitled_navision.xlsb" S55 = "s55_not_osv_titles.xlsx" S56 = "s56_almost_osv_tables.xlsx" S57 = "s57_form_mentions_osv.xlsx" OTHER_SYSTEMS_FILES = (S48, S49, S50, S51, S52, S53, S54, S55, S56, S57) S48_SHEETS = ("ОВ", "ОСВ сокр", "Оборотный баланс", "Навижн", "Главная книга", "Верхний регистр", "Вторая колонка") TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 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", "s14_statement_forms.xlsx", "s15_group_title_synonyms.xlsx", "s16_header_synonyms.xlsx", "s17_inn_in_name.xlsx", "s18_inn_row_above.xlsx", "s19_inn_kpp_cell.xlsx", *INN_LAYOUT_FILES, INN_BAD_CHECKSUM_FILE, XLS_NO_CODEPAGE_FILE, VAL_KOL_VO_FILE, ROWS_BELOW_TOTAL_FILE, TOTAL_WITH_SUFFIX_FILE, "s33_indicator_column_bu_nu.xlsx", "s34_indicator_column_qty_val_diff.xlsx", "s35_currency_slices.xlsx", "s36_no_data_type_header.xlsx", *FLAT_FORM_FILES, *OTHER_SYSTEMS_FILES, ] XLSB_NAME = "s13_no_outline_indent.xlsb" XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)" ORG = 'ООО "Тестовая организация"' VARIANT_2 = 2 XLSB_WARNING = "xlsb: outline level недоступен" ALFA = "ООО Альфа" BETA = "ООО Бета" GAMMA = "ООО Гамма" D = Decimal @dataclass(frozen=True) class Expect: """Ожидания по файлу, общие для всех сценариев (шапка и итог разбора).""" hierarchy: str year: int | None quarter: str | None cumulative: bool | None status: ResultStatus = ResultStatus.DONE documents: int = 1 variant: int = 1 data_type: str | None = "BU" organization: str = ORG # Вариант шапки по документам (если у листов файла он разный); пусто — `variant` у всех variants: tuple[int, ...] = () 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, status=ResultStatus.NEEDS_CHECK, data_type="NU"), "s13_no_outline_indent.xlsb": Expect("indent", 2025, "Q2", False), # три листа форм по одному признаку + лист ОСВ "s14_statement_forms.xlsx": Expect("outline", 2025, "Q1", True, status=ResultStatus.NEEDS_CHECK), # названия групп сумм: «Входящее/Конечное», «Начальное/Исходящее сальдо» "s15_group_title_synonyms.xlsx": Expect("outline", 2025, "Q1", True, documents=2), # шапка без организации, период «2024»; лист 1 — вариант 1, лист 2 — вариант 2 "s16_header_synonyms.xlsx": Expect("outline", 2024, "Q4", True, documents=2, organization="", variants=(1, 2)), # ИНН в тексте названия, колонки ИНН нет "s17_inn_in_name.xlsx": Expect("outline", 2025, "Q1", True), # ИНН строкой над названием "s18_inn_row_above.xlsx": Expect("outline", 2025, "Q2", False), # колонка «ИНН/КПП» "s19_inn_kpp_cell.xlsx": Expect("outline", 2025, "Q3", False), # восемь раскладок строки ИНН (#35) **{name: Expect("outline", 2025, "Q1", True) for name in INN_LAYOUT_FILES}, # ИНН с неверной контрольной суммой: строка остаётся строкой, документ на проверку INN_BAD_CHECKSUM_FILE: Expect("outline", 2025, "Q1", True, status=ResultStatus.NEEDS_CHECK), # xls BIFF5 без CODEPAGE, строки cp1251; отступ пробелами XLS_NO_CODEPAGE_FILE: Expect("indent", 2025, "Q1", True), # счёт 10, вариант 2: материал «Вал» и строки «Кол-во» VAL_KOL_VO_FILE: Expect("outline", 2025, "Q2", False, variant=2), # подпись и блок чисел под «Итого» ROWS_BELOW_TOTAL_FILE: Expect("outline", 2025, "Q1", True), # итог подписан «Итого по счету 60»: точного «Итого» нет TOTAL_WITH_SUFFIX_FILE: Expect("outline", 2025, "Q1", True), "s33_indicator_column_bu_nu.xlsx": Expect("outline", 2025, "Q3", False, data_type=None), "s34_indicator_column_qty_val_diff.xlsx": Expect("outline", 2025, "Q3", False), "s35_currency_slices.xlsx": Expect("outline", 2025, "Q3", False), "s36_no_data_type_header.xlsx": Expect("outline", 2025, "Q3", False, data_type=None), # плоская форма 1С (#59): в шапке нет признака данных (БУ/НУ), `header.data_type = None`; строки — BU (#58) # счёт в шапке «по счету: 76.Н.1», данные без оформления; период «за 2025 г.» "s40_flat_header_account.xlsx": Expect( "header_account", 2025, "Q4", True, data_type=None, organization='ООО "Товарное хозяйство"' ), # вид «Организации», «за 2 Квартал 2026 г. План счетов …» "s41_flat_chart_of_accounts.xlsx": Expect( "header_account", 2026, "Q2", False, data_type=None, organization="ООО Хреновская Нива" ), # субсчета 76.2…76.Н.1 по шаблону кода; «Итого развернутое» перед «Итого» "s42_subaccounts_totals_reversed.xlsx": Expect( "account_pattern", 2025, "Q4", True, data_type=None, organization='ООО "Товарное хозяйство"' ), # счёт 60 без вида аналитики в шапке, отступы "s43_subkonto_unnamed_60.xlsx": Expect( "indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"' ), # то же на счёте 10: материалы остаются OTHER "s44_subkonto_unnamed_10.xlsx": Expect( "indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"' ), # #71: «Счет Поставщика | 60*», «За период | 01.01.25..30.06.25» -> 2025, Q2, нарастающий "s45_account_label_value_cells.xlsx": Expect("header_account", 2025, "Q2", True, data_type=None), # #71: выгрузка «по контрагентам» целиком: блоки субсчетов, коды контрагентов, договоры, «ИТОГ по …», «ИТОГ» "s46_counterparty_code_blocks.xlsx": Expect("code_blocks", 2025, "Q2", True, data_type=None), # #78: листы 68/90/91 по отступам: подуровень «68.01.1», безымянная строка закрытия, вложенность по суммам "s47_indent_hierarchy_code_sums.xlsx": Expect( "indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"', documents=3 ), # #81: названия других систем, остатки, SAP; безымянный лист — без шапки (организация, период, вид данных пусты) S48: Expect("outline", 2025, "Q1", True, documents=len(S48_SHEETS)), S49: Expect("outline", 2025, "Q1", True, documents=2), S50: Expect("outline", 2025, "Q1", True, documents=2), S51: Expect("outline", None, None, None, data_type=None, organization=""), S52: Expect("outline", None, None, None, status=ResultStatus.NEEDS_CHECK, data_type=None, organization=""), S53: Expect("indent", 2025, "Q1", True, documents=2), S54: Expect("indent", None, None, None, data_type=None, organization=""), S55: Expect("outline", 2025, "Q1", True), S56: Expect("outline", 2025, "Q1", True), S57: Expect("outline", None, None, None, status=ResultStatus.NEEDS_CHECK, documents=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(): 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 """Документы ОСВ результата; документы форм (`StatementDocument`) разбираются отдельными сценариями.""" return [doc for doc in outcome.documents if isinstance(doc, OsvDocument)] def one_document(outcome: ParseOutcome) -> OsvDocument: (doc,) = documents(outcome) return doc def warnings_of(outcome: ParseOutcome) -> list[str]: """Тексты предупреждений документов ОСВ (контракт 2.0: лежат в самих документах), по порядку документов.""" return [w.message for doc in documents(outcome) for w in doc.warnings] def file_warnings_of(outcome: ParseOutcome) -> list[FileWarning]: """Предупреждения файла (`metrics.warnings`).""" 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…s36, s40…s44 (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 ([], [XLSB_NAME]), 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_code = f"OSV_HIERARCHY_BY_{expect.hierarchy.upper()}" hierarchy_notes = [w for doc in docs for w in doc.warnings if w.code == hierarchy_code] assert len(hierarchy_notes) == expect.documents, warnings_of(outcome) assert all(w.severity == "info" and w.code.startswith("OSV_") for doc in docs for w in doc.warnings) variants = expect.variants or (expect.variant,) * expect.documents assert [doc.header.header_variant for doc in docs] == list(variants) for doc in docs: 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 == expect.organization assert doc.rows[-1].group_type == "TOTAL" if expect.status is ResultStatus.DONE: assert doc.status == "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 [(w.code, w.severity, w.locator) for w in file_warnings_of(outcome)] == [ ("SHEET_OTHER", "info", "sheet:Справка") ] 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. T11.2: смешанные знаки у детей (Альфа — кредитовое, Бета — дебетовое) намеренно проверяют правило R-21: свёрнутое сальдо родителя сверяется по нетто (дебет минус кредит), а не по сумме модулей. """ 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_goes_to_statement_pipeline(fixtures_dir: Path) -> None: """«Лист ББ + лист ОСВ»: документ ОСВ и документ ББ; у синтетического ББ нет шапки и колонки кодов → needs_check.""" outcome = parse_named(fixtures_dir, "s12_statement_and_osv.xlsx") (doc,) = documents(outcome) assert doc.locator == "sheet:ОСВ 60" assert [(d.document_type, d.locator) for d in outcome.documents] == [ ("OSV", "sheet:ОСВ 60"), ("BALANCE", "sheet:Бухгалтерский баланс"), ] assert outcome.status is ResultStatus.NEEDS_CHECK 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) neutral = {"locator": "", "warnings": []} assert xlsb_doc.model_copy(update=neutral) == xlsx_doc.model_copy(update=neutral) assert xlsb.status is xlsx.status is ResultStatus.DONE assert [(w.code, w.message) for w in xlsb_doc.warnings if w.code == "OSV_XLSB_NO_OUTLINE"] == [ ("OSV_XLSB_NO_OUTLINE", XLSB_WARNING) ] assert [w for w in xlsb_doc.warnings if w.code != "OSV_XLSB_NO_OUTLINE"] == xlsx_doc.warnings assert "OSV_HIERARCHY_BY_INDENT" in {w.code for w in xlsb_doc.warnings} assert xlsb.metrics is not None assert xlsx.metrics is not None assert xlsb.metrics.rows == xlsx.metrics.rows def test_s14_each_statement_form_is_recognised_by_its_own_marker(fixtures_dir: Path) -> None: """Три листа форм (код ОКУД 0710001, «Отчет о финансовых результатах», код 0710002) дают три документа форм.""" outcome = parse_named(fixtures_dir, "s14_statement_forms.xlsx") (doc,) = documents(outcome) assert doc.locator == "sheet:ОСВ 60" statements = [(d.document_type, d.locator) for d in outcome.documents if d.document_type != "OSV"] assert statements == [("BALANCE", "sheet:Форма 1"), ("INCOME", "sheet:ОФР"), ("INCOME", "sheet:Форма 2")] assert outcome.status is ResultStatus.NEEDS_CHECK def test_s15_group_title_synonyms_find_all_six_sum_columns(fixtures_dir: Path) -> None: """«Входящее/Конечное сальдо» (лист 60), «Начальное/Исходящее сальдо» (лист 62): шесть колонок, суммы верны.""" outcome = parse_named(fixtures_dir, "s15_group_title_synonyms.xlsx") first, second = documents(outcome) assert (first.locator, second.locator) == ("sheet:ОСВ 60", "sheet:ОСВ 62") alfa = by_text(first)[ALFA] assert sums(alfa) == (None, D("250000.50"), D("180000.25"), D("420000.00"), None, D("490000.25")) buyer = by_text(second)["ООО Покупатель-1"] assert sums(buyer) == (D("500000.00"), None, D("320000.00"), D("410000.00"), D("410000.00"), None) assert outcome.status is ResultStatus.DONE def test_s16_header_without_organization_year_only_period_and_currency_note(fixtures_dir: Path) -> None: """Шапка без организации (предупреждение), период «2024» -> Q4, валюта «RUB (код 643)» -> RUB, метки группировок отдельными строками («Счет» среди них), «Детализация: …» без слова «субконто» и группировки вне ключевых.""" outcome = parse_named(fixtures_dir, "s16_header_synonyms.xlsx") first, second = documents(outcome) assert first.header.organization_name == "" assert any("организации в шапке не найдено" in w for w in warnings_of(outcome)) assert (first.header.period_text, first.header.period_year, first.header.period_quarter) == ("2024", 2024, "Q4") assert first.header.currency == "RUB" assert [group.type for group in first.group_schema] == ["ACCOUNT", "COUNTERPARTY"] assert second.header.header_variant == VARIANT_2 assert [(group.type, group.name) for group in second.group_schema] == [ ("ACCOUNT", "Субконто"), ("OTHER", "Номенклатура"), ("OTHER", "Статьи затрат"), ("OTHER", "Нематериальные активы"), ("OTHER", "Валюта"), ] assert outcome.status is ResultStatus.DONE def test_s17_inn_in_name_is_extracted_and_name_is_clean(fixtures_dir: Path) -> None: """OQ-100: ИНН из текста названия («, », «(ИНН …)», «ИНН: …», пробел) — в `counterparty_inn`, имя без ИНН.""" doc = one_document(parse_named(fixtures_dir, "s17_inn_in_name.xlsx")) counterparties = { row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY" } assert counterparties == { ALFA: "7701234567", BETA: "7707654321", GAMMA: "5001234567", "ИП Сидоров": "500100732259", "Иванов Иван": None, } alfa = next(row for row in doc.rows if row.counterparty_name == ALFA) assert alfa.row_value == "ООО Альфа, 7701234567" # исходный текст не меняется assert doc.columns is not None assert "inn" not in doc.columns def test_s18_inn_row_above_is_glued_and_not_other(fixtures_dir: Path) -> None: """OQ-130: строка с одним ИНН над названием не попадает в строки и не уходит в OTHER, ИНН — у контрагента.""" outcome = parse_named(fixtures_dir, "s18_inn_row_above.xlsx") doc = one_document(outcome) assert not [row for row in doc.rows if row.group_type == "OTHER"] assert not [row for row in doc.rows if row.row_value in {"7701234560", "5001234561"}] counterparties = { row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY" } assert counterparties == {ALFA: "7701234560", BETA: None, "Иванов Иван": None, GAMMA: "5001234561"} assert any("ИНН 7701234560" in warning for warning in warnings_of(outcome)) INN_LAYOUT_COUNTERPARTIES = { ALFA: "7701234560", BETA: "7707654321", "Иванов Иван": None, GAMMA: "5001234561", "ИП Сидоров": "500100732259", } @pytest.mark.parametrize("name", INN_LAYOUT_FILES) def test_inn_row_layouts_give_inn_to_own_counterparty(fixtures_dir: Path, name: str) -> None: """#35: в каждой из восьми раскладок строки ИНН у каждого контрагента — ИНН его строки, строк ИНН в `rows` нет.""" outcome = parse_named(fixtures_dir, name) doc = one_document(outcome) assert outcome.status is ResultStatus.DONE, warnings_of(outcome) counterparties = { row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY" } assert counterparties == INN_LAYOUT_COUNTERPARTIES assert not [row for row in doc.rows if row.group_type == "OTHER"] inns = {inn for inn in INN_LAYOUT_COUNTERPARTIES.values() if inn} assert not [row for row in doc.rows if row.row_value in inns] def test_s28_bad_checksum_inn_row_stays_a_row_and_needs_check(fixtures_dir: Path) -> None: """Опечатка в ИНН ООО Альфа: «7701234567» остаётся обычной строкой, у Альфы ИНН нет, предупреждение, проверка; остальные контрагенты получают свои ИНН (направление «над» — по блоку 60.02).""" outcome = parse_named(fixtures_dir, INN_BAD_CHECKSUM_FILE) doc = one_document(outcome) assert outcome.status is ResultStatus.NEEDS_CHECK counterparties = { row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY" } assert counterparties.pop("7701234567") is None # обычная строка: классификатор принял её за контрагента assert counterparties == {**INN_LAYOUT_COUNTERPARTIES, ALFA: None} assert any( "похожа на ИНН 7701234567" in warning and "контрольная сумма не сходится" in warning for warning in warnings_of(outcome) ) # решение 20: предупреждение названо для строки 13, строка остаётся COUNTERPARTY без ИНН, в OTHER не уходит assert any(f"строка {TYPO_ROW}: похожа на ИНН 7701234567" in warning for warning in warnings_of(outcome)) typo_rows = [row for row in doc.rows if row.row_number == TYPO_ROW] assert [(row.row_value, row.group_type, row.counterparty_inn) for row in typo_rows] == [ ("7701234567", "COUNTERPARTY", None) ] assert not [row for row in doc.rows if row.group_type == "OTHER"] def test_s19_inn_kpp_cell_gives_inn_only(fixtures_dir: Path) -> None: """«ИНН/КПП» в одной ячейке: в `counterparty_inn` — только ИНН; колонка распознана как колонка ИНН.""" doc = one_document(parse_named(fixtures_dir, "s19_inn_kpp_cell.xlsx")) counterparties = { row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY" } assert counterparties == { ALFA: "7701234567", BETA: "7707654321", "Иванов Иван": None, GAMMA: "5001234567", "ООО Дельта": "7812345678", } assert doc.columns is not None assert doc.columns["inn"] == "E" def test_s29_xls_without_codepage_is_read_as_cp1251(fixtures_dir: Path) -> None: """AURORA-1662: в книге BIFF5 нет CODEPAGE — кириллица читается как cp1251, предупреждение об этом есть.""" outcome = parse_named(fixtures_dir, XLS_NO_CODEPAGE_FILE) doc = one_document(outcome) assert outcome.status is ResultStatus.DONE assert doc.header.organization_name == ORG assert {ALFA, BETA, GAMMA} <= set(rows_text(doc)) assert [(w.code, w.severity, w.locator, w.message) for w in file_warnings_of(outcome)] == [ ("XLS_NO_CODEPAGE", "info", None, NO_CODEPAGE_WARNING) ] assert "OSV_XLS_NO_CODEPAGE" not in {w.code for w in doc.warnings} def test_s30_val_without_dot_is_a_material_kol_vo_is_quantity(fixtures_dir: Path) -> None: """AURORA-1663: «Вал» без точки — номенклатура (не CURRENCY), «Кол-во» — QUANTITY.""" doc = one_document(parse_named(fixtures_dir, VAL_KOL_VO_FILE)) (shaft,) = [row for row in doc.rows if row.row_value == "Вал"] assert shaft.data_type == doc.header.data_type assert shaft.group_type == "OTHER" quantities = [row for row in doc.rows if row.row_value == "Кол-во"] assert [row.data_type for row in quantities] == ["QUANTITY", "QUANTITY"] assert not [row for row in doc.rows if row.data_type == "CURRENCY"] def test_s31_rows_below_total_are_outside_the_document(fixtures_dir: Path) -> None: """AURORA-1664: таблица кончается на первой «Итого»; подпись и блок с числами ниже — не строки, а предупреждение.""" outcome = parse_named(fixtures_dir, ROWS_BELOW_TOTAL_FILE) doc = one_document(outcome) assert outcome.status is ResultStatus.DONE assert rows_text(doc)[-1] == "Итого" assert [row.group_type for row in doc.rows].count("TOTAL") == 1 assert not {"Ответственный:", "ООО Альфа (ГК)", "Итого по ГК"} & set(rows_text(doc)) assert any("ниже таблицы" in warning for warning in warnings_of(outcome)), warnings_of(outcome) def test_s42_secondary_total_before_main_total(fixtures_dir: Path) -> None: """#59: «Итого развернутое» перед «Итого» — обе TOTAL, сверка по основной «Итого», статус done.""" outcome = parse_named(fixtures_dir, "s42_subaccounts_totals_reversed.xlsx") doc = one_document(outcome) assert outcome.status is ResultStatus.DONE assert doc.status == "done" assert [row.group_type for row in doc.rows[-2:]] == ["TOTAL", "TOTAL"] assert doc.checks is not None assert doc.checks.total_row_matches_root is True assert doc.checks.parent_sum_violations == [] assert doc.checks.balance_identity_violations == [] def test_s32_total_with_suffix_is_total(fixtures_dir: Path) -> None: """I-1: нет точного «Итого» — итогом служит строка, начинающаяся с «Итого» («Итого по счету 60»).""" doc = one_document(parse_named(fixtures_dir, TOTAL_WITH_SUFFIX_FILE)) assert doc.rows[-1].row_value == "Итого по счету 60" assert doc.rows[-1].group_type == "TOTAL" def test_s33_indicator_column_bu_nu(fixtures_dir: Path) -> None: """AURORA-1613: строки «НУ» колонки «Показатели» — показатели строки БУ; две «Итого»; проверки сходятся.""" outcome = parse_named(fixtures_dir, "s33_indicator_column_bu_nu.xlsx") doc = one_document(outcome) by_row = {row.row_number: row for row in doc.rows} nu = [row for row in doc.rows if row.data_type == "NU"] assert [row.row_value for row in nu] == ["НУ"] * 4 assert all(row.group_type in {"OTHER", "TOTAL"} for row in nu) for row in nu: if row.group_type != "TOTAL": parent = by_row[row.parent_row_number] assert parent.data_type == "BU" and parent.row_number == row.row_number - 1 assert row.level == parent.level + 1 assert [row.data_type for row in doc.rows if row.group_type == "TOTAL"] == ["BU", "NU"] assert {row.data_type for row in doc.rows if row.row_value != "НУ"} == {"BU"} assert doc.header.data_type is None texts = warnings_of(outcome) assert not any("ниже таблицы" in text for text in texts), texts assert not any("приняты как БУ" in text for text in texts), texts assert outcome.status is ResultStatus.DONE def test_s34_indicator_column_quantity_currency_differences(fixtures_dir: Path) -> None: """«Кол.» — QUANTITY, «Вал.» — CURRENCY, «ПР»/«ВР»/неизвестная метка — null; одно предупреждение о метке.""" outcome = parse_named(fixtures_dir, "s34_indicator_column_qty_val_diff.xlsx") doc = one_document(outcome) types = {row.row_value: row.data_type for row in doc.rows if row.row_value in {"Вал.", "Кол.", "ПР", "ВР", "Ед."}} assert types == {"Вал.": "CURRENCY", "Кол.": "QUANTITY", "ПР": None, "ВР": None, "Ед.": None} unknown = [text for text in warnings_of(outcome) if "«Ед.»" in text] assert len(unknown) == 1, warnings_of(outcome) assert outcome.status is ResultStatus.DONE def test_s35_v_valyute_is_currency_of_previous_row(fixtures_dir: Path) -> None: """AURORA-1661: «В валюте» — CURRENCY, родитель — «Валюта …» над ней; «Валюта …» — обычные строки в рублях.""" outcome = parse_named(fixtures_dir, "s35_currency_slices.xlsx") doc = one_document(outcome) by_row = {row.row_number: row for row in doc.rows} in_currency = [row for row in doc.rows if row.row_value == "В валюте"] assert [row.data_type for row in in_currency] == ["CURRENCY", "CURRENCY"] assert [by_row[row.parent_row_number].row_value for row in in_currency] == ["Валюта USD", "Валюта EUR"] slices = [row for row in doc.rows if row.row_value.startswith("Валюта ")] assert {(row.group_type, row.data_type) for row in slices} == {("OTHER", "BU")} assert outcome.status is ResultStatus.DONE def test_s36_no_data_type_in_header_rows_are_bu(fixtures_dir: Path) -> None: """Шапка без «Выводимые данные»: строки — BU, предупреждение одно, `header.data_type` — null.""" outcome = parse_named(fixtures_dir, "s36_no_data_type_header.xlsx") doc = one_document(outcome) assert doc.header.data_type is None assert {row.data_type for row in doc.rows} == {"BU"} assert len([text for text in warnings_of(outcome) if "приняты как БУ" in text]) == 1 assert outcome.status is ResultStatus.DONE # --- ОСВ других учётных систем (#81): контрольный набор --- ALFA_SUMS = (None, D("250000.50"), D("180000.25"), D("420000.00"), None, D("490000.25")) BUYER_SUMS = (D("500000.00"), None, D("320000.00"), D("410000.00"), D("410000.00"), None) TOTAL_NOT_FOUND = "строка «Итого» не найдена" def sheet_other(outcome: ParseOutcome) -> list[str]: """Листы, пропущенные как прочие (`SHEET_OTHER`), по порядку.""" warnings = outcome.metrics.warnings if outcome.metrics is not None else None return [w.locator or "" for w in warnings or [] if w.code == "SHEET_OTHER"] def assert_done_with_sums(doc: OsvDocument, text: str, expected: tuple[Decimal | None, ...]) -> None: """Документ сошёлся, и у строки `text` шесть сумм по сторонам: перепутанные Дебет/Кредит здесь не пройдут.""" assert sums(by_text(doc)[text]) == expected assert doc.status == "done", doc.checks assert doc.rows[-1].group_type == "TOTAL" def test_s48_other_system_titles_are_osv(fixtures_dir: Path) -> None: """Каждое название ведомости — свой лист ОСВ; «ВСЕГО» и «Всего » — итоговая строка (AURORA-1717, 1732).""" outcome = parse_named(fixtures_dir, S48) docs = documents(outcome) assert [doc.locator for doc in docs] == [f"sheet:{name}" for name in S48_SHEETS] assert sheet_other(outcome) == [] for doc in docs: assert_done_with_sums(doc, ALFA, ALFA_SUMS) totals = {doc.locator: doc.rows[-1].row_value.strip() for doc in docs} assert totals["sheet:Главная книга"] == "ВСЕГО" assert totals["sheet:Вторая колонка"] == "Всего" assert not [text for text in warnings_of(outcome) if TOTAL_NOT_FOUND in text] def test_s49_balance_and_turnover_by_side(fixtures_dir: Path) -> None: """«Остаток на начало/конец», «Оборот по дебету/кредиту», «Остаток по дебету на начало»; преамбула «Остаток на начало: 0» над таблицей строкой групп не считается.""" outcome = parse_named(fixtures_dir, S49) first, second = documents(outcome) assert (first.locator, second.locator) == ("sheet:Остатки", "sheet:Остатки по сторонам") assert_done_with_sums(first, ALFA, ALFA_SUMS) assert_done_with_sums(second, "ООО Покупатель-1", BUYER_SUMS) def test_s50_sap_in_out_and_glued_side(fixtures_dir: Path) -> None: """SAP «Сальдо вх/исх Дт/Кт» и сторона, приклеенная к группе («ДТВВ»); «ООО Дтвв-Сервис» — контрагент.""" outcome = parse_named(fixtures_dir, S50) sap, glued = documents(outcome) assert (sap.locator, glued.locator) == ("sheet:SAP", "sheet:НМЖК") assert_done_with_sums(sap, ALFA, ALFA_SUMS) assert_done_with_sums(glued, "ООО Дтвв-Сервис", ALFA_SUMS) assert by_text(glued)["ООО Дтвв-Сервис"].group_type == "COUNTERPARTY" def test_s51_untitled_sheet_is_osv_by_table(fixtures_dir: Path) -> None: """Navision без названия ведомости: ОСВ по полному заголовку таблицы (запасной признак).""" outcome = parse_named(fixtures_dir, S51) doc = one_document(outcome) assert doc.locator == "sheet:ОВ" assert sheet_other(outcome) == [] assert_done_with_sums(doc, ALFA, ALFA_SUMS) def test_s52_form_stays_form_navision_is_osv_reference_is_other(fixtures_dir: Path) -> None: """Форма ББ — свой документ (лист Navision не присоединяется к ней слабым продолжением, R-12), Navision — ОСВ, «Справка» — прочий лист.""" outcome = parse_named(fixtures_dir, S52) assert [(d.document_type, d.locator) for d in outcome.documents] == [ ("OSV", "sheet:ОВ"), ("BALANCE", "sheet:Бухгалтерский баланс"), ] assert sheet_other(outcome) == ["sheet:Справка"] assert_done_with_sums(one_document(outcome), ALFA, ALFA_SUMS) def test_s53_xls_balance_by_side(fixtures_dir: Path) -> None: """s49 в xls (без объединений): те же колонки и суммы.""" outcome = parse_named(fixtures_dir, S53) first, second = documents(outcome) assert_done_with_sums(first, ALFA, ALFA_SUMS) assert_done_with_sums(second, "ООО Покупатель-1", BUYER_SUMS) def test_s54_xlsb_untitled_navision(fixtures_dir: Path) -> None: """s51 в xlsb: ОСВ по запасному признаку, те же суммы.""" outcome = parse_named(fixtures_dir, S54) assert sheet_other(outcome) == [] assert_done_with_sums(one_document(outcome), ALFA, ALFA_SUMS) def test_neg_s55_titles_like_osv_are_other(fixtures_dir: Path) -> None: """Название не с начала ячейки, «Оборотный капитал», 1С «Обороты счета» с корр. счетами между «Оборот Дт» и «Оборот Кт» (R-1, R-11) — прочие листы; ОСВ одна.""" outcome = parse_named(fixtures_dir, S55) # «Входные данные» (перечень документов шаблона банка, R-13) пакет форм относит к расшифровке — как реальный лист # «Входные данные_ИП» на базе; главное — не ОСВ и книга не падает assert [(d.document_type, d.locator) for d in outcome.documents] == [ ("OSV", "sheet:ОСВ 60"), ("DISCLOSURE", "sheet:Входные данные"), ] assert sheet_other(outcome) == ["sheet:Справка", "sheet:Капитал", "sheet:Обороты счета"] def test_neg_s56_almost_osv_tables_are_other(fixtures_dir: Path) -> None: """Расшифровка с остатками, таблица без «начала», свёрнутое начало, заголовок ниже 30-й строки — прочие листы.""" outcome = parse_named(fixtures_dir, S56) assert [doc.locator for doc in documents(outcome)] == ["sheet:ОСВ 60"] assert sheet_other(outcome) == [ "sheet:Расшифровка", "sheet:Без начала", "sheet:Свёрнутое начало", "sheet:Ниже 30", "sheet:Преамбула", ] def test_neg_s57_form_with_osv_words_stays_form(fixtures_dir: Path) -> None: """Форма ББ с примечанием «см. оборотную ведомость» и слабым продолжением — форма из двух листов, ОСВ нет.""" outcome = parse_named(fixtures_dir, S57) assert [(d.document_type, d.locator) for d in outcome.documents] == [ ("BALANCE", "sheet:Бухгалтерский баланс,Баланс (2)") ] assert sheet_other(outcome) == []