counterparty_accounts в osv_groups.yaml (R-9: без 71 и 73), is_counterparty_account, OSV_ROWS по счёту; формулировка проверки «Итого» без корня ACCOUNT (T4.1); ожидания s40–s44 в синтетике (T1.1/T1.2), тест испорченной «Итого» плоского списка (T4.2). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1216 lines
50 KiB
Python
1216 lines
50 KiB
Python
"""Классификация строк ОСВ (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"),
|
||
("Вал.", "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:
|
||
"""Review Focus 3: без обычной «Итого» «Итого развернутое» — единственная итоговая строка."""
|
||
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-9) ---
|
||
|
||
|
||
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
|