Andrey Epifancev 26ed11ad28 ОСВ: строки ИНН — подключение прохода, подсказка шапки в обоих порядках, старые склейки удалены (#35)
Хвосты ревью Task 3: формулировка направления листа в spec/design (T3.1), тесты противоречия сведений и приоритета подсказки (T3.2), assert на glue.ok (T3.4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:27:02 +04:00

942 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Классификация строк ОСВ (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.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
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")
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_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_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]