"""Классификация строк ОСВ (spec «Классификация строк», design D-4, `05-domain-rules.md` §1.2–§1.4). Сетки собираются `gridbuilder`; конвейер теста — тот же, что у парсера: заголовок таблицы → схема группировок → `collect_body_rows` → каскад иерархии → `classify_rows`. Позиции колонок в сетках произвольные: классификатор знает их только из `TableHeader`. """ from dataclasses import dataclass from decimal import Decimal import pytest from gridbuilder import C, L, M, sheet_from_rows from src.contract.common import DataType from src.parser.classify import ACCOUNT_RE, ClassifiedRow, classify_rows, collect_body_rows from src.parser.deadline import Deadline from src.parser.detect import find_table_header from src.parser.errors import ParseTimeoutError from src.parser.grid import Sheet from src.parser.header import build_group_levels from src.parser.hierarchy import TRIVIAL_STRATEGY, Levels, resolve_levels from src.parser.indicators import row_data_type from src.parser.inn_rows import glue_inn_rows from src.parser.rules import Rules, load_rules TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г." OPENING = "Сальдо на начало периода" TURNOVER = "Обороты за период" CLOSING = "Сальдо на конец периода" TIMEOUT_SECONDS = 60 EXPIRED_AFTER_SECONDS = 1000 TAIL_NUMBERS_MARK = "ниже таблицы" ACCOUNT_ROW = 7 # первая строка данных в сетке `osv(...)`: шапка 3 строки + блок заголовка 3 строки DUPLICATED_EXTRA_COLUMNS = 2 Sums = tuple[object, object, object, object, object, object] NO_SUMS: Sums = (None, None, None, None, None, None) @pytest.fixture(scope="module") def rules() -> Rules: return load_rules() @dataclass(frozen=True) class Parsed: rows: list[ClassifiedRow] warnings: list[str] levels: Levels total_row: int | None def by_text(self, text: str) -> list[ClassifiedRow]: return [row for row in self.rows if row.body.text == text] def one(self, text: str) -> ClassifiedRow: found = self.by_text(text) assert len(found) == 1, (text, [row.body.text for row in self.rows]) return found[0] def parse( sheet: Sheet, rules: Rules, doc_data_type: DataType | None = "BU", deadline: Deadline | None = None ) -> Parsed: deadline = deadline or Deadline(TIMEOUT_SECONDS) header = find_table_header(sheet, rules) schema = build_group_levels(sheet, header, rules) body, total_row, warnings = collect_body_rows(sheet, header, rules, deadline) levels = resolve_levels(body, total_row, deadline) glue = glue_inn_rows( body, levels, total_row, sheet, inn_col=header.inn_col, schema=schema.groups, hint=schema.inn_hint, rules=rules ) rows = classify_rows( glue.rows, glue.levels, schema.groups, header, sheet, doc_data_type, total_row, rules, inns=glue.inns, unpaired=glue.unpaired, ) return Parsed(rows, [*warnings, *glue.warnings], glue.levels, total_row) # --- Сетка: «Счет | ИНН | КПП | начало Дт/Кт | обороты Дт/Кт | конец Дт/Кт», группировки Счет/Контрагенты/Договоры --- def _head() -> list[list[object]]: return [ ["ООО Ромашка"], [TITLE], [], ["Счет", "ИНН", "КПП", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 4 ["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], # 5 ["Договоры"], # 6 ] def r(level: int | None, text: object, sums: Sums = NO_SUMS, inn: object = None, kpp: object = None) -> list[object]: """Строка данных: outline `level` (`None` — без группировки Excel), текст, ИНН, КПП и шесть сумм.""" cells: list[object] = [text, inn, kpp, *sums] return cells if level is None else [L(level), *cells] def osv(*rows: list[object]) -> Sheet: return sheet_from_rows([*_head(), *rows]) S100: Sums = (100, None, 50, 30, 120, None) def _basic() -> Sheet: return osv( r(0, "60", S100), # 7 r(1, "60.01", S100), # 8 r(2, "ООО Поставщик", S100, inn="7701234567", kpp="770101001"), # 9 r(3, "Договор № 1 от 01.02.2025", S100), # 10 r(None, "Итого", S100), # 11 ) # --- Регулярка счёта --- @pytest.mark.parametrize( ("text", "code"), [ ("60", "60"), ("60.01", "60.01"), ("76.07.1", "76.07.1"), ("001", "001"), ("60.1", "60.1"), ("60.01, Расчеты с поставщиками", "60.01"), # R-16: буквенные субсчета плана счетов 1С (НДС по авансам и т. п.) ("76.АВ", "76.АВ"), ("76.ВА", "76.ВА"), ("76.ОТ", "76.ОТ"), ("76.АВ, НДС по авансам и предоплатам", "76.АВ"), ("76.АВ.1", "76.АВ.1"), ("19.ЁЖ", "19.ЁЖ"), ("60.AB", "60.AB"), ], ) def test_account_re_matches_account_codes(text: str, code: str) -> None: found = ACCOUNT_RE.match(text) assert found is not None assert found.group(1) == code def test_account_re_tolerates_no_space_after_comma() -> None: """R-18: намеренный допуск шире текста спеки (там после запятой обязателен пробел): «60,Расчеты» — счёт 60.""" found = ACCOUNT_RE.match("60,Расчеты") assert found is not None assert found.group(1) == "60" @pytest.mark.parametrize( "text", [ "6", "6001", "60.001", "60.01.1.1", "ООО 60", "60 лет Октября", "Итого", "12.2025 г.", "76.АВГД", # R-16: буквенный сегмент — не длиннее трёх букв "76.ав", # только прописные: без IGNORECASE "76.Ав", "76.А1", ], ) def test_account_re_rejects_non_accounts(text: str) -> None: assert ACCOUNT_RE.match(text) is None def test_account_re_residual_false_positive_documented() -> None: """Известный остаточный ложноположительный случай R-16: «12.ООО» (две цифры, точка, до трёх прописных букв) принимается за счёт; описан в docstring модуля `classify`. """ assert ACCOUNT_RE.match("12.ООО") is not None def test_account_re_accepts_multiline_name_after_comma() -> None: """M-1: многострочная ячейка «60.01, Расчеты\nс поставщиками» — счёт 60.01 (re.DOTALL), а не контрагент.""" found = ACCOUNT_RE.match("60.01, Расчеты\nс поставщиками") assert found is not None assert found.group(1) == "60.01" assert found.group(2) == "Расчеты\nс поставщиками" @pytest.mark.parametrize("text", ["٦٠.01", "60.٠١", "60", "60.01\nРасчеты"]) def test_account_re_accepts_only_ascii_digits(text: str) -> None: """M-1: re.ASCII — `\\d` только 0–9 (арабско-индийские и полноширинные цифры — не код счёта); без запятой перевод строки после кода не делает строку счётом.""" assert ACCOUNT_RE.match(text) is None def test_multiline_account_row_keeps_children_group_types(rules: Rules) -> None: """M-1: многострочное имя субсчёта не сдвигает group_type детей (контрагент остаётся контрагентом).""" sheet = osv( r(0, "60", S100), r(1, "60.01, Расчеты\nс поставщиками", S100), r(2, "ООО Поставщик", S100, inn="7701234567"), r(None, "Итого", S100), ) parsed = parse(sheet, rules) subaccount = parsed.one("60.01, Расчеты\nс поставщиками") assert (subaccount.group_type, subaccount.account_code_raw, subaccount.body.account_rank) == ("ACCOUNT", "60.01", 2) assert parsed.one("ООО Поставщик").group_type == "COUNTERPARTY" def test_letter_subaccount_is_account_with_children(rules: Rules) -> None: """R-16: «76.АВ» — субсчёт (ранг 2, сосед «76.04»), его дети — по схеме: контрагент, договор.""" sheet = osv( r(0, "76", S100), r(1, "76.04", S100), r(2, "ООО Первый", S100, inn="7701234567"), r(1, "76.АВ, НДС по авансам", S100), # 10 r(2, "Русский бисквит", S100, inn="3528052718"), # 11 r(3, "Договор № 5", S100), r(None, "Итого", S100), ) parsed = parse(sheet, rules) letter = parsed.one("76.АВ, НДС по авансам") assert (letter.group_type, letter.account_code_raw, letter.body.account_rank) == ("ACCOUNT", "76.АВ", 2) counterparty = parsed.one("Русский бисквит") assert (counterparty.group_type, counterparty.account_code_raw, counterparty.parent) == ( "COUNTERPARTY", "76.АВ", letter.body.row, ) assert counterparty.counterparty_inn == "3528052718" assert parsed.one("Договор № 5").group_type == "CONTRACT" def test_letter_subaccount_is_sibling_in_account_pattern(rules: Rules) -> None: """R-16: без outline и отступов (стратегия account_pattern) «76.АВ» — сосед «76.04», а не его ребёнок.""" sheet = osv( r(None, "76", S100), # 7 r(None, "76.04", S100), # 8 r(None, "ООО Первый", S100), # 9 r(None, "76.АВ", S100), # 10 r(None, "Русский бисквит", S100), # 11 r(None, "Итого", S100), ) parsed = parse(sheet, rules) assert parsed.levels.strategy == "account_pattern" sub_04, letter = parsed.one("76.04"), parsed.one("76.АВ") assert (letter.level, letter.parent) == (sub_04.level, sub_04.parent) == (1, ACCOUNT_ROW) assert parsed.one("Русский бисквит").parent == letter.body.row assert parsed.one("Русский бисквит").group_type == "COUNTERPARTY" # --- Группы по позиции в схеме --- def test_account_subaccount_counterparty_contract(rules: Rules) -> None: parsed = parse(_basic(), rules) kinds = [(row.body.text, row.group_type, row.group_name, row.account_code_raw, row.level) for row in parsed.rows] assert kinds == [ ("60", "ACCOUNT", "Счет", "60", 0), ("60.01", "ACCOUNT", "Счет", "60.01", 1), ("ООО Поставщик", "COUNTERPARTY", "Контрагенты", "60.01", 2), # schema[1] ("Договор № 1 от 01.02.2025", "CONTRACT", "Договоры", "60.01", 3), # schema[2] ("Итого", "TOTAL", None, None, 0), ] assert parsed.levels.strategy == "outline" def test_parents_follow_tree(rules: Rules) -> None: parsed = parse(_basic(), rules) assert [row.parent for row in parsed.rows] == [None, 7, 8, 9, None] def test_beyond_schema_is_other(rules: Rules) -> None: sheet = osv( r(0, "60", S100), r(1, "ООО Поставщик", S100, inn="7701234567"), r(2, "Договор № 1", S100), r(3, "Спецификация 7", S100), r(None, "Итого", S100), ) parsed = parse(sheet, rules) beyond = parsed.one("Спецификация 7") assert (beyond.group_type, beyond.group_name, beyond.account_code_raw) == ("OTHER", None, "60") assert parsed.one("ООО Поставщик").group_type == "COUNTERPARTY" assert parsed.one("Договор № 1").group_type == "CONTRACT" def test_account_with_name_after_comma(rules: Rules) -> None: sheet = osv( r(0, "60", S100), r(1, "60.01, Расчеты с поставщиками", S100), r(2, "ООО Поставщик", S100), r(None, "Итого", S100), ) parsed = parse(sheet, rules) account = parsed.one("60.01, Расчеты с поставщиками") assert (account.group_type, account.account_code_raw) == ("ACCOUNT", "60.01") assert account.body.text == "60.01, Расчеты с поставщиками" # row_value — исходный текст assert parsed.one("ООО Поставщик").account_code_raw == "60.01" def test_same_counterparty_under_different_subaccounts(rules: Rules) -> None: """Review Focus 3: одноимённые контрагенты различаются родителем и счётом ветки, а не именем.""" sheet = osv( r(0, "66", S100), r(1, "66.03", (None, None, 40, 40, None, None)), # 8 r(2, "Юг-Финанс", (None, None, 40, 40, None, None)), # 9 r(1, "66.04", (100, None, 10, -10, 120, None)), # 10 r(2, "Юг-Финанс", (100, None, 10, -10, 120, None)), # 11 r(None, "Итого", S100), ) parsed = parse(sheet, rules) first, second = parsed.by_text("Юг-Финанс") assert (first.parent, first.account_code_raw) == (8, "66.03") assert (second.parent, second.account_code_raw) == (10, "66.04") # --- Контрагент и ИНН --- @pytest.mark.parametrize( ("inn", "expected"), [ ("7701234567", "7701234567"), ("ИНН 770123456789", "770123456789"), (Decimal("123456789"), "0123456789"), # число с потерянным ведущим нулём ("№ 12 от 01.02.2025", None), ("12345", None), (None, None), ], ) def test_counterparty_inn_from_inn_column(rules: Rules, inn: object, expected: str | None) -> None: sheet = osv( r(0, "60", S100), r(1, "ООО Поставщик", S100, inn=inn), r(1, "ООО Второй", (None, None, None, None, None, None), inn="7702222222"), r(1, "ООО Третий", (None, None, None, None, None, None), inn="7703333333"), r(None, "Итого", S100), ) parsed = parse(sheet, rules) counterparty = parsed.one("ООО Поставщик") assert counterparty.group_type == "COUNTERPARTY" assert counterparty.counterparty_name == "ООО Поставщик" assert counterparty.counterparty_inn == expected def test_counterparty_fields_only_on_counterparty_rows(rules: Rules) -> None: parsed = parse(_basic(), rules) assert [(row.counterparty_name, row.counterparty_inn) for row in parsed.rows] == [ (None, None), (None, None), ("ООО Поставщик", "7701234567"), (None, None), (None, None), ] # --- data_type строк --- def test_currency_and_quantity_rows(rules: Rules) -> None: sheet = osv( r(0, "60", S100), r(1, "ООО Поставщик", S100, inn="7701234567"), r(2, "USD", (5, None, 1, None, 6, None)), r(2, "Кол.", (3, None, None, None, 3, None)), r(2, "Вал.", (5, None, 1, None, 6, None)), r(2, "eur", (5, None, 1, None, 6, None)), r(None, "Итого", S100), ) parsed = parse(sheet, rules, doc_data_type="NU") types = {row.body.text: row.data_type for row in parsed.rows} assert types == { "60": "NU", "ООО Поставщик": "NU", "USD": "CURRENCY", "Кол.": "QUANTITY", "Вал.": "CURRENCY", "eur": "CURRENCY", "Итого": "NU", } usd = parsed.one("USD") assert usd.body.sums[0] == Decimal("5.00") # суммы остаются в шести полях # Строка-показатель — не группировка: OTHER, а не следующая группа схемы (CONTRACT) assert (usd.group_type, usd.group_name, usd.account_code_raw) == ("OTHER", None, "60") @pytest.mark.parametrize( ("text", "expected"), [ ("Кол.", "QUANTITY"), ("Количество", "QUANTITY"), ("Кол-во", "QUANTITY"), # AURORA-1663, O-5 ("кол-во.", "QUANTITY"), # единица измерения после запятой (AURORA-1709, ОСВ 43.1: строка под позицией) ("Кол-во, га", "QUANTITY"), ("Кол-во, кг", "QUANTITY"), ("Количество, шт.", "QUANTITY"), ("Колхоз, ОАО", None), ("Колос", None), ("Кол-во, га, кг", None), ("Вал.", "CURRENCY"), ("USD", "CURRENCY"), ("Вал", None), # AURORA-1663, O-6: «Вал» без точки — наименование материала (ОСВ 10.1), не метка валюты ("Вал стальной", None), ], ) def test_row_data_type_labels(rules: Rules, text: str, expected: str | None) -> None: assert row_data_type(text, rules, frozenset(rules.data_types.currency_codes)) == expected def test_doc_data_type_none_passes_through(rules: Rules) -> None: parsed = parse(_basic(), rules, doc_data_type=None) assert {row.data_type for row in parsed.rows} == {None} # --- extra --- def test_extra_from_unmatched_columns(rules: Rules) -> None: parsed = parse(_basic(), rules) assert parsed.one("ООО Поставщик").extra == {"КПП": "770101001"} assert parsed.one("60").extra is None # все значения пусты assert parsed.one("Итого").extra is None def test_account_row_without_sums_and_duplicate_extra_titles(rules: Rules) -> None: """Review Focus 1: строка счёта со всеми null не падает; две колонки с одним заголовком — разные ключи extra.""" sheet = sheet_from_rows( [ ["ООО Ромашка"], [TITLE], [], ["Счет", "Кол-во", "Кол-во", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], [L(0), "60"], # счёт без сумм [L(1), "ООО Поставщик", "1", "2", None, None, 10, None, 10, None], [L(1), "ООО Второй", None, "3"], ["Итого", None, None, None, None, 10, None, 10, None], ] ) parsed = parse(sheet, rules) account = parsed.one("60") assert account.group_type == "ACCOUNT" assert account.body.sums == (None,) * 6 assert account.extra is None first = parsed.one("ООО Поставщик").extra assert first is not None assert len(first) == len(set(first)) == DUPLICATED_EXTRA_COLUMNS assert sorted(value or "" for value in first.values()) == ["1", "2"] second = parsed.one("ООО Второй").extra assert second is not None assert set(second) == set(first) assert sorted(value or "" for value in second.values()) == ["", "3"] def test_footer_after_total_is_not_data_nor_extra(rules: Rules) -> None: """T4.5: подвал после «Итого» (подписи, числа правее таблицы) не попадает ни в строки, ни в extra.""" sheet = osv( r(0, "60", S100), r(1, "ООО Поставщик", S100, inn="7701234567"), r(None, "Итого", S100), [], ["Главный бухгалтер", None, "Иванова"], # подпись в колонке КПП [None, None, None, None, None, None, None, None, None, None, 375490642], # правее таблицы ) parsed = parse(sheet, rules) assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик", "Итого"] assert all(row.extra is None for row in parsed.rows) assert parsed.total_row == parsed.one("Итого").body.row def test_table_ends_at_first_total_everything_below_is_outside(rules: Rules) -> None: """AURORA-1664 (O-12): под таблицей дописаны подпись и блок аналитика, в нём есть и строка «Итого …»: таблица кончается на первой «Итого», хвост вне документа — ни строкой, ни TOTAL; числа в хвосте — предупреждение. """ sheet = osv( r(0, "60", S100), r(1, "ООО Поставщик", S100, inn="7701234567"), r(None, "Итого", S100), [], ["Ответственный:", None, "Иванова"], r(None, "ООО Альфа (ГК)", (7, None, 5, None, 12, None)), r(None, "Итого по ГК", (7, None, 5, None, 12, None)), ) parsed = parse(sheet, rules) assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик", "Итого"] assert parsed.total_row == parsed.one("Итого").body.row assert [row.group_type for row in parsed.rows].count("TOTAL") == 1 assert any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings), parsed.warnings def test_text_only_tail_below_total_gives_no_warning(rules: Rules) -> None: sheet = osv( r(0, "60", S100), r(None, "Итого", S100), ["Ответственный:", None, "Иванова"], ) parsed = parse(sheet, rules) assert not any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings), parsed.warnings def test_second_total_then_tail_numbers(rules: Rules) -> None: """«Итого развернутое» сразу за «Итого» — ещё таблица; числа ниже неё — хвост.""" sheet = osv( r(0, "60", S100), r(None, "Итого", S100), r(None, "Итого развернутое", S100), r(None, "Итого", (1, None, None, None, 1, None)), ) parsed = parse(sheet, rules) assert [row.body.text for row in parsed.rows] == ["60", "Итого", "Итого развернутое"] assert any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings) def test_numbers_right_of_table_below_total_give_no_warning(rules: Rules) -> None: """Контрольные суммы правее таблицы под «Итого» — не хвост таблицы: предупреждения нет.""" sheet = osv( r(0, "60", S100), r(None, "Итого", S100), [None, None, None, None, None, None, None, None, None, None, None, 375490642], ) parsed = parse(sheet, rules) assert not any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings), parsed.warnings def test_column_filled_only_in_footer_is_not_an_extra_key(rules: Rules) -> None: """T4.5: колонка, заполненная только в подвале, не становится ключом `extra` у строк таблицы.""" sheet = sheet_from_rows( [ ["ООО Ромашка"], [TITLE], [], ["Счет", "КПП", "Код", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], [L(0), "60", None, None, 100, None, None, None, 100, None], [L(1), "ООО Поставщик", "770101001", None, 100, None, None, None, 100, None], ["Итого", None, None, 100, None, None, None, 100, None], ["Главный бухгалтер", None, "подпись"], ] ) parsed = parse(sheet, rules) assert parsed.one("ООО Поставщик").extra == {"КПП": "770101001"} def test_rows_with_values_only_right_of_table_are_skipped(rules: Rules) -> None: sheet = osv( r(0, "60", S100), [None, None, None, None, None, None, None, None, None, None, 777], # только правее таблицы r(1, "ООО Поставщик", S100, inn="7701234567"), r(None, "Итого", S100), ) parsed = parse(sheet, rules) assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик", "Итого"] # --- Пустые строки, пробелы, повтор заголовка --- def test_blank_and_whitespace_rows_do_not_break_tree(rules: Rules) -> None: """Review Focus 5: пустая строка и строка из пробелов между блоками пропускаются, дерево не рвётся.""" sheet = osv( r(0, "60", S100), # 7 [], # 8 [" ", " ", None, " "], # 9 r(1, "ООО Поставщик", S100, inn="7701234567"), # 10 r(None, "Итого", S100), # 11 ) parsed = parse(sheet, rules) assert [row.body.row for row in parsed.rows] == [7, 10, 11] assert parsed.one("ООО Поставщик").parent == ACCOUNT_ROW def test_header_repeat_is_skipped(rules: Rules) -> None: """Review Focus 4: повтор заголовка внутри данных (печать по страницам) — не строка данных и не OTHER.""" sheet = osv( r(0, "60", S100), # 7 ["Счет", "ИНН", "КПП", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 8 ["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], # 9 r(1, "ООО Поставщик", S100, inn="7701234567"), # 10 r(None, "Итого", S100), ) parsed = parse(sheet, rules) assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик", "Итого"] assert parsed.one("ООО Поставщик").parent == ACCOUNT_ROW # --- ИНН в тексте строки контрагента (issue #9, OQ-100, OQ-130) --- def _inn_sheet(name: str, sums: Sums = S100) -> Sheet: return osv(r(0, "60", sums), r(1, name, sums)) @pytest.mark.parametrize( ("text", "name", "inn"), [ ("ООО Поставщик, 7701234567", "ООО Поставщик", "7701234567"), ("ООО Поставщик,7701234567", "ООО Поставщик", "7701234567"), ("ООО Поставщик (7701234567)", "ООО Поставщик", "7701234567"), ("ООО Поставщик (ИНН 7701234567)", "ООО Поставщик", "7701234567"), ("ООО Поставщик ИНН 7701234567", "ООО Поставщик", "7701234567"), ("ООО Поставщик ИНН: 7701234567", "ООО Поставщик", "7701234567"), ("ООО Поставщик - ИНН 7701234567", "ООО Поставщик", "7701234567"), ("ООО Поставщик 7701234567", "ООО Поставщик", "7701234567"), ("ООО Поставщик / 7701234567", "ООО Поставщик", "7701234567"), ("ИП Сидоров, 500100732259", "ИП Сидоров", "500100732259"), ("ООО Поставщик, 7701234567/770101001", "ООО Поставщик", "7701234567"), ("Альянс ООО (Тамбов) 6829133099", "Альянс ООО (Тамбов)", "6829133099"), ], ) def test_inn_is_extracted_from_counterparty_name(rules: Rules, text: str, name: str, inn: str) -> None: parsed = parse(_inn_sheet(text), rules) row = parsed.rows[1] assert row.group_type == "COUNTERPARTY" assert (row.counterparty_name, row.counterparty_inn) == (name, inn) assert row.body.text == text # исходный текст строки сохраняется (row_value) @pytest.mark.parametrize( "text", [ "ООО Поставщик 12345", # не 10/12 цифр "ООО Поставщик 12345678901", # 11 цифр "ООО Поставщик 1234567890123", # 13 цифр "ООО Поставщик 7701234567 филиал", # ИНН не в конце "ООО Поставщик№7701234567", # без разделителя "7701234567", # весь текст — число: имени нет ], ) def test_inn_is_not_extracted_from_foreign_text(rules: Rules, text: str) -> None: row = parse(_inn_sheet(text), rules).rows[1] assert (row.counterparty_name, row.counterparty_inn) == (text, None) def test_inn_column_wins_over_inn_in_name_and_name_is_still_cleaned(rules: Rules) -> None: sheet = osv(r(0, "60", S100), r(1, "ООО Поставщик, 7701234567", S100, inn="7707654321")) row = parse(sheet, rules).rows[1] assert (row.counterparty_name, row.counterparty_inn) == ("ООО Поставщик", "7707654321") def test_inn_in_contract_name_is_not_extracted(rules: Rules) -> None: sheet = osv( r(0, "60", S100), r(1, "ООО Поставщик", S100, inn="7701234567"), r(2, "Договор № 1, 7701234567", S100), ) contract = parse(sheet, rules).rows[2] assert contract.group_type == "CONTRACT" assert (contract.counterparty_name, contract.counterparty_inn) == (None, None) def test_inn_with_kpp_in_one_column_cell_gives_inn(rules: Rules) -> None: sheet = osv(r(0, "60", S100), r(1, "ООО Поставщик", S100, inn="7701234567 / 770101001")) row = parse(sheet, rules).rows[1] assert (row.counterparty_name, row.counterparty_inn) == ("ООО Поставщик", "7701234567") def test_inn_only_row_above_is_glued_to_next_counterparty(rules: Rules) -> None: sheet = osv( r(0, "60", S100), r(1, "7701234560"), # ИНН строкой выше названия, без сумм (контрольная сумма верна) r(1, "ООО Поставщик", S100), ) parsed = parse(sheet, rules) assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик"] supplier = parsed.rows[1] assert (supplier.counterparty_name, supplier.counterparty_inn, supplier.parent) == ( "ООО Поставщик", "7701234560", ACCOUNT_ROW, ) assert any("7701234560" in warning for warning in parsed.warnings), parsed.warnings def test_inn_row_above_with_label_and_kpp_forms(rules: Rules) -> None: sheet = osv( r(0, "60", S100), r(1, "ИНН 7701234560 / 770101001"), r(1, "ООО Поставщик", S100), ) assert parse(sheet, rules).rows[1].counterparty_inn == "7701234560" def test_inn_row_above_in_tree_without_extra_level_is_not_glued(rules: Rules) -> None: # Схема «Контрагенты, Договоры», глубже схемы строк нет — «7701234560» контрагент, «ООО Поставщик» под ним (защита) sheet = osv( r(0, "60", S100), r(1, "7701234560", S100), r(2, "ООО Поставщик", S100), ) parsed = parse(sheet, rules) assert [row.body.text for row in parsed.rows] == ["60", "7701234560", "ООО Поставщик"] assert parsed.one("ООО Поставщик").counterparty_inn is None def test_inn_row_above_glues_to_name_without_sums(rules: Rules) -> None: sheet = osv( r(0, "60", S100), r(1, "7701234560"), r(1, "ООО Поставщик"), r(1, "ООО Другой", S100), ) parsed = parse(sheet, rules) assert "7701234560" not in [row.body.text for row in parsed.rows] assert parsed.one("ООО Поставщик").counterparty_inn == "7701234560" def test_inn_row_with_bad_checksum_is_left_as_is(rules: Rules) -> None: sheet = osv(r(0, "60", S100), r(1, "7701234567"), r(1, "ООО Поставщик", S100)) parsed = parse(sheet, rules) assert "7701234567" in [row.body.text for row in parsed.rows] assert parsed.one("ООО Поставщик").counterparty_inn is None assert any("контрольная сумма не сходится" in warning for warning in parsed.warnings), parsed.warnings def test_unpaired_inn_row_is_other(rules: Rules) -> None: sheet = osv(r(0, "60", S100), r(1, "ООО Поставщик", S100), r(1, "7701234560"), r(1, "ООО Другой", S100)) parsed = parse(sheet, rules) inn_row = parsed.one("7701234560") assert (inn_row.group_type, inn_row.counterparty_name, inn_row.counterparty_inn) == ("OTHER", None, None) # --- TOTAL --- def test_empty_osv_total_without_sums(rules: Rules) -> None: """R-13: пустая ОСВ — только «Итого» без сумм: одна строка TOTAL, иерархия trivial.""" parsed = parse(osv(r(None, "Итого")), rules) assert [(row.group_type, row.level, row.parent) for row in parsed.rows] == [("TOTAL", 0, None)] assert parsed.levels.strategy == TRIVIAL_STRATEGY assert parsed.total_row == parsed.rows[0].body.row def test_total_without_sums_in_account_pattern_sheet_stays_root(rules: Rules) -> None: """T6-pointer: «Итого» без сумм выбирается по тексту и положению до уровней — иначе account_pattern сделал бы его ребёнком последнего счёта. """ sheet = osv( r(None, "60", S100), r(None, "60.01", S100), r(None, "ООО Поставщик", S100), r(None, "Итого"), ) parsed = parse(sheet, rules) assert parsed.levels.strategy == "account_pattern" total = parsed.one("Итого") assert (total.group_type, total.level, total.parent) == ("TOTAL", 0, None) # Негатив (T8.5): то же «Итого», не переданное в каскад, account_pattern вложил бы под последний счёт header = find_table_header(sheet, rules) body, _, _ = collect_body_rows(sheet, header, rules, Deadline(TIMEOUT_SECONDS)) unpinned = resolve_levels(body, None, Deadline(TIMEOUT_SECONDS)) assert unpinned.by_row[total.body.row] > 0 def test_second_total_row_after_total_is_total_too(rules: Rules) -> None: """Issue #11 п. 5: «Итого развернутое» после «Итого» — вторая итоговая строка (TOTAL, уровень 0), не OTHER.""" sheet = osv( r(0, "60.01", S100), r(1, "ООО Поставщик", S100, inn="7701234567"), r(None, "Итого", S100), r(None, "Итого развернутое", S100), [], ["Главный бухгалтер", None, "Иванова"], ) parsed = parse(sheet, rules) assert [row.body.text for row in parsed.rows] == ["60.01", "ООО Поставщик", "Итого", "Итого развернутое"] assert parsed.total_row == parsed.one("Итого").body.row # основная — первая из подряд идущих итоговых for text in ("Итого", "Итого развернутое"): total = parsed.one(text) assert (total.group_type, total.level, total.parent) == ("TOTAL", 0, None) def test_second_total_row_alone_serves_as_total(rules: Rules) -> None: """Если обычной «Итого» нет, «Итого развернутое» — единственная итоговая строка и сверяется с корнем.""" sheet = osv( r(0, "60", S100), r(1, "ООО Поставщик", S100, inn="7701234567"), r(None, "Итого развернутое", S100), ) parsed = parse(sheet, rules) assert parsed.total_row == parsed.one("Итого развернутое").body.row assert parsed.one("Итого развернутое").group_type == "TOTAL" def test_text_after_total_that_is_not_a_second_total_stays_footer(rules: Rules) -> None: sheet = osv( r(0, "60", S100), r(None, "Итого", S100), r(None, "Руководитель", S100), ) parsed = parse(sheet, rules) assert [row.body.text for row in parsed.rows] == ["60", "Итого"] def test_counterparty_named_like_total_in_the_middle(rules: Rules) -> None: """T3.3: «Итого-Сервис» в середине данных — контрагент; TOTAL — последняя строка «Итого».""" sheet = osv( r(0, "60", S100), r(1, "Итого-Сервис", S100, inn="7701234567"), r(None, "Итого", S100), ) parsed = parse(sheet, rules) assert parsed.one("Итого-Сервис").group_type == "COUNTERPARTY" assert parsed.one("Итого").group_type == "TOTAL" assert [row.group_type for row in parsed.rows].count("TOTAL") == 1 def test_flat_counterparty_named_like_total_before_exact_total(rules: Rules) -> None: """I-1: плоский лист без outline и отступов; «Итого-Сервис» с суммами в середине — строка данных, а не конец таблицы: TOTAL — точное «Итого», данных ниже контрагента не теряется, хвоста нет.""" sheet = osv( r(None, "60", (110, None, 50, 30, 130, None)), r(None, "Итого-Сервис", S100, inn="7701234567"), r(None, "ООО Поставщик", (10, None, None, None, 10, None), inn="7707654321"), r(None, "Итого", (110, None, 50, 30, 130, None)), ) parsed = parse(sheet, rules) assert [row.body.text for row in parsed.rows] == ["60", "Итого-Сервис", "ООО Поставщик", "Итого"] assert parsed.total_row == parsed.one("Итого").body.row assert parsed.one("Итого-Сервис").group_type != "TOTAL" assert not any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings), parsed.warnings def test_last_row_named_like_total_is_rejected_by_outline_level(rules: Rules) -> None: """Последняя строка «Итого-Сервис» без настоящего «Итого»: outline глубже минимального — не TOTAL.""" sheet = osv( r(0, "60", S100), r(1, "Итого-Сервис", S100, inn="7701234567"), ) parsed = parse(sheet, rules) assert parsed.total_row is None assert parsed.one("Итого-Сервис").group_type == "COUNTERPARTY" def test_known_risk_last_counterparty_named_like_total_without_outline(rules: Rules) -> None: """Остаточный риск (документирован): только когда точного «Итого» нет вовсе, а на листе нет outline и отступов, первая строка «Итого-Сервис» на корневом уровне неотличима от итоговой строки и становится TOTAL; контрольные суммы §1.4 (TOTAL = корень) это поймают, если суммы не совпадают. """ sheet = osv( r(None, "60", S100), r(None, "Итого-Сервис", (1, None, None, None, 1, None)), ) parsed = parse(sheet, rules) assert parsed.one("Итого-Сервис").group_type == "TOTAL" # --- Свёрнутое сальдо (R-1) --- def test_collapsed_closing_split_by_sign(rules: Rules) -> None: """Spec «ОСВ без сальдо на начало и со свёрнутым сальдо»: отрицательное свёрнутое — в closing_credit.""" sheet = sheet_from_rows( [ ["ООО Ромашка"], [TITLE], [], ["Счет", M(TURNOVER, 2), M(CLOSING, 1, 2)], ["Контрагенты", "Дебет", "Кредит", None], [L(0), "60", 10, 30, -20], [L(1), "ООО Первый", 10, None, 10], [L(1), "ООО Второй", None, 30, -30], [L(1), "ООО Третий", None, None, 0], [L(1), "ООО Четвёртый"], ["Итого", 10, 30, -20], ] ) parsed = parse(sheet, rules) sums = {row.body.text: row.body.sums for row in parsed.rows} two = Decimal("0.00") assert sums["ООО Первый"] == (None, None, Decimal("10.00"), None, Decimal("10.00"), None) assert sums["ООО Второй"] == (None, None, None, Decimal("30.00"), None, Decimal("30.00")) assert sums["ООО Третий"] == (None, None, None, None, two, None) assert sums["ООО Четвёртый"] == (None,) * 6 assert sums["Итого"] == (None, None, Decimal("10.00"), Decimal("30.00"), None, Decimal("20.00")) collapsed = [warning for warning in parsed.warnings if "свёрнутое сальдо" in warning] assert len(collapsed) == 1 assert "closing" in collapsed[0] def test_no_collapsed_warning_for_regular_sheet(rules: Rules) -> None: parsed = parse(_basic(), rules) assert parsed.warnings == [] # --- Суммы и предупреждения --- def test_sums_in_column_map_order_and_money_warnings_with_row(rules: Rules) -> None: sheet = osv( r(0, "60", ("1 234,50", "—", "(10,00)", "abc", Decimal("0.005"), None)), # 7 r(None, "Итого"), ) parsed = parse(sheet, rules) assert parsed.one("60").body.sums == ( Decimal("1234.50"), None, Decimal("-10.00"), None, Decimal("0.01"), None, ) assert len(parsed.warnings) == 1 assert "строка 7" in parsed.warnings[0] assert "abc" in parsed.warnings[0] def test_body_row_style_fields(rules: Rules) -> None: sheet = osv( r(None, C("60", bold=True), S100), r(None, C("ООО Поставщик", indent=2), S100), r(None, "Итого", S100), ) header = find_table_header(sheet, rules) body, _, _ = collect_body_rows(sheet, header, rules, Deadline(TIMEOUT_SECONDS)) account, counterparty, _ = body assert (account.is_account, account.account_rank, account.bold) == (True, 1, True) assert (counterparty.is_account, counterparty.account_rank, counterparty.indent) == (False, None, 2) def test_account_rank_counts_segments(rules: Rules) -> None: sheet = osv( r(0, "76", S100), r(1, "76.07", S100), r(2, "76.07.1, Прочие", S100), r(None, "Итого", S100), ) header = find_table_header(sheet, rules) body, _, _ = collect_body_rows(sheet, header, rules, Deadline(TIMEOUT_SECONDS)) assert [row.account_rank for row in body] == [1, 2, 3, None] # --- Дедлайн --- def test_collect_body_rows_checks_deadline(rules: Rules) -> None: data = [r(1, f"ООО Поставщик {index}", S100) for index in range(1200)] sheet = osv(r(0, "60", S100), *data, r(None, "Итого", S100)) header = find_table_header(sheet, rules) # Часы: 0 при создании дедлайна, дальше — далеко за лимитом; первая же проверка внутри обхода строк бросает readings = iter([0.0]) deadline = Deadline(EXPIRED_AFTER_SECONDS, clock=lambda: next(readings, EXPIRED_AFTER_SECONDS * 2.0)) with pytest.raises(ParseTimeoutError): collect_body_rows(sheet, header, rules, deadline) def test_classified_rows_cover_every_body_row(rules: Rules) -> None: parsed = parse(_basic(), rules) assert [row.level for row in parsed.rows] == [parsed.levels.by_row[row.body.row] for row in parsed.rows] # --- Колонка «Показатели» (#58) --- INDICATOR_TOTAL_ROW = 7 def _indicator_sheet() -> Sheet: return sheet_from_rows( [ [TITLE], ["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", "Показатели", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], [L(0), "60", "БУ", 100, None, 50, 30, 120, None], # 4 [L(1), M("ООО Поставщик", 1, 2), "БУ", 100, None, 50, 30, 120, None], # 5 [L(1), None, "НУ", 90, None, 40, 30, 100, None], # 6 [L(0), "Итого", "БУ", 100, None, 50, 30, 120, None], # 7 [L(0), None, "НУ", 90, None, 40, 30, 100, None], # 8 ] ) def test_indicator_column_rows_are_labelled_indicators(rules: Rules) -> None: sheet = _indicator_sheet() header = find_table_header(sheet, rules) body, total_row, warnings = collect_body_rows(sheet, header, rules, Deadline(TIMEOUT_SECONDS)) by_row = {item.row: item for item in body} assert sorted(by_row) == [4, 5, 6, 7, 8] assert total_row == INDICATOR_TOTAL_ROW assert not any(TAIL_NUMBERS_MARK in warning for warning in warnings) assert [by_row[row].indicator for row in (4, 5, 6, 7, 8)] == [False, False, True, False, True] assert [by_row[row].text for row in (6, 8)] == ["НУ", "НУ"] assert [by_row[row].label_data_type for row in (4, 5, 6, 7, 8)] == ["BU", "BU", "NU", "BU", "NU"] assert by_row[5].text == "ООО Поставщик" assert not by_row[6].is_account def test_indicator_rows_and_totals_are_classified_by_flag(rules: Rules) -> None: parsed = parse(_indicator_sheet(), rules, None) by_row = {item.body.row: item for item in parsed.rows} nu = by_row[6] assert (nu.group_type, nu.data_type, nu.indicator) == ("OTHER", "NU", True) assert (nu.account_code_raw, nu.counterparty_name, nu.counterparty_inn) == ("60", None, None) nu_total = by_row[8] assert (nu_total.group_type, nu_total.data_type, nu_total.indicator) == ("TOTAL", "NU", True) assert by_row[7].indicator is False assert by_row[7].data_type == "BU" def test_label_data_type_wins_over_document_data_type(rules: Rules) -> None: parsed = parse(_indicator_sheet(), rules, "NU") assert {item.body.row: item.data_type for item in parsed.rows if not item.indicator} == { 4: "BU", 5: "BU", 7: "BU", } def test_row_without_label_takes_document_data_type(rules: Rules) -> None: """Строка без метки получает вид данных шапки; умолчание БУ подставляет `osv.py` до вызова классификатора.""" sheet = osv(r(0, "60", S100), r(1, "ООО Поставщик", S100)) assert {item.body.text: item.data_type for item in parse(sheet, rules, "BU").rows} == { "60": "BU", "ООО Поставщик": "BU", } assert {item.body.text: item.data_type for item in parse(sheet, rules, "NU").rows} == { "60": "NU", "ООО Поставщик": "NU", } def test_secondary_total_before_main_total_is_in_total_block(rules: Rules) -> None: """Плоская форма (#59): «Итого развернутое» стоит перед «Итого» — обе строки итоговый блок, `total_row` — первая.""" sheet = osv( r(0, "60.01", S100), r(0, "60.02", S100), r(None, "Итого развернутое", (1, None, 2, 3, 4, None)), r(None, "Итого", (200, None, 100, 60, 240, None)), ) parsed = parse(sheet, rules) secondary, main = parsed.one("Итого развернутое"), parsed.one("Итого") assert parsed.total_row == secondary.body.row assert [row.group_type for row in (secondary, main)] == ["TOTAL", "TOTAL"] assert [row.level for row in (secondary, main)] == [0, 0] assert [row.parent for row in (secondary, main)] == [None, None] assert parsed.one("60.02").group_type != "TOTAL" def test_only_secondary_total_is_the_total(rules: Rules) -> None: """Без обычной «Итого» «Итого развернутое» — единственная итоговая строка.""" sheet = osv(r(0, "60", S100), r(None, "Итого развернутое", S100)) parsed = parse(sheet, rules) secondary = parsed.one("Итого развернутое") assert parsed.total_row == secondary.body.row assert secondary.group_type == "TOTAL" # --- Контрагенты расчётных счетов без названия вида (#59, R-6) --- def _subkonto_sheet(*rows: list[object], label: str | None = None) -> Sheet: """Схема из одной ACCOUNT («Субконто»); `label` — вторая группировка в блоке заголовка.""" block: list[list[object]] = [["Субконто", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)]] block.append([label, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"]) return sheet_from_rows([["ООО Ромашка"], [TITLE], [], *block, *rows]) def test_unnamed_first_level_on_settlement_account_is_counterparty(rules: Rules) -> None: sheet = _subkonto_sheet( [L(0), "60.01", *S100], [L(1), "ИП Иванов А.А.", *S100], [L(0), "Итого", *S100], ) parsed = parse(sheet, rules) row = parsed.one("ИП Иванов А.А.") assert row.group_type == "COUNTERPARTY" assert row.group_name is None assert row.counterparty_name == "ИП Иванов А.А." assert row.counterparty_by_account is True def test_unnamed_second_level_stays_other(rules: Rules) -> None: sheet = _subkonto_sheet( [L(0), "60.01", *S100], [L(1), "ИП Иванов А.А.", *S100], [L(2), "Договор № 1", *S100], [L(0), "Итого", *S100], ) parsed = parse(sheet, rules) row = parsed.one("Договор № 1") assert row.group_type == "OTHER" assert row.counterparty_by_account is False def test_named_other_group_is_not_reclassified(rules: Rules) -> None: sheet = _subkonto_sheet( [L(0), "76.01", *S100], [L(1), "Станок", *S100], [L(0), "Итого", *S100], label="Основные средства", ) parsed = parse(sheet, rules) row = parsed.one("Станок") assert row.group_type == "OTHER" assert row.group_name == "Основные средства" assert row.counterparty_by_account is False def test_non_settlement_account_stays_other(rules: Rules) -> None: sheet = _subkonto_sheet( [L(0), "10.01", *S100], [L(1), "Мука пшеничная", *S100], [L(0), "Итого", *S100], ) parsed = parse(sheet, rules) row = parsed.one("Мука пшеничная") assert row.group_type == "OTHER" assert row.counterparty_by_account is False @pytest.mark.parametrize("text", ["Валюта", "Валюта USD", "Валюта CNY", "Валюта руб", "В валюте"]) def test_currency_row_under_settlement_account_is_not_counterparty(rules: Rules, text: str) -> None: """R-18: строка валюты под 76.55 — не контрагент по плану счетов, а контрагент рядом остаётся контрагентом.""" sheet = _subkonto_sheet( [L(0), "76.55", *S100], [L(1), "ООО Ромашка", *S100], [L(1), text, *S100], [L(0), "Итого", *S100], ) parsed = parse(sheet, rules) row = parsed.one(text) assert row.group_type == "OTHER" assert row.counterparty_by_account is False assert parsed.one("ООО Ромашка").group_type == "COUNTERPARTY"