Andrey Epifancev a833945669 feat(parser): детекторы листов ОСВ, заголовка таблицы и колонок по содержимому
detect.py: classify_sheet (ОСВ / форма ББ-ОФР / прочее по словарю), find_table_header
(строка с ≥ 2 группами сумм, «Дебет/Кредит» под группой в пределах merged-диапазона
или до следующей группы, заголовок в 1–4 строки, в т.ч. «Нач. сальдо Дт» в одной
ячейке; без «начала»; свёрнутое сальдо в *_debit + collapsed; свёрнутые «Обороты» и
половинчатая пара Дт/Кт — InvalidFormatError с названием группы), text_col, inn_col
(заголовок «ИНН» или ≥ 80 % значений из 10/12 цифр), right_edge, extra_cols,
group_label_rows, first_data_row, is_header_repeat (две группы или копия строки блока).

Pattern.search_normalized — ячейка нормализуется один раз (T3.6). osv_groups: известные
группировки OTHER перечислены явно, чтобы строка-метка «Основные средства» в
неслитом заголовке была частью блока. Хелпер тестов gridbuilder (Sheet из «картинки»).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 12:54:11 +04:00

534 lines
22 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.

"""Словари `rules/*.yaml` и загрузчик: нормализация текста, паттерны, таблица периодов §1.5, группировки §1.1,
типы данных §1.3, диагностика битых словарей."""
import shutil
from dataclasses import fields, is_dataclass
from pathlib import Path
import pytest
import yaml
from pydantic import BaseModel
from src.parser.rules import (
DEFAULT_RULES_DIR,
OsvGroupRules,
Pattern,
Rules,
first_match,
load_rules,
normalize_text,
)
# Символы, которых не должно быть в паттернах словарей: нормализованный текст их не содержит (ё → е, дефисы и
# неразрывные пробелы → пробел), такой паттерн никогда бы не сработал
MIN_PATTERNS = 40 # защита от «пустого» обхода дерева словарей
ISO_CODE_LENGTH = 3
FORBIDDEN_IN_PATTERN = "ёЁ-\u2010\u2011\u2012\u2013\u2014\u2015\u2212\u00a0\n\r\t"
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def _all_patterns(node: object) -> list[Pattern]:
"""Все `Pattern` в дереве словарей (модели, списки, кортежи, словари)."""
if isinstance(node, Pattern):
return [node]
if isinstance(node, BaseModel):
return _all_patterns(list(dict(node).values()))
if is_dataclass(node) and not isinstance(node, type):
return _all_patterns([getattr(node, field.name) for field in fields(node)])
if isinstance(node, dict):
return _all_patterns(list(node.values()))
if isinstance(node, list | tuple):
return [found for item in node for found in _all_patterns(item)]
return []
def _copy_rules(tmp_path: Path) -> Path:
target = tmp_path / "rules"
shutil.copytree(DEFAULT_RULES_DIR, target, ignore=shutil.ignore_patterns("README.md"))
return target
# --- normalize_text ---
@pytest.mark.parametrize(
("raw", "expected"),
[
("Оборотно-сальдовая\nведомость", "оборотно сальдовая ведомость"),
("ОБОРОТНО\u2013САЛЬДОВАЯ", "оборотно сальдовая"),
("Оборотно\u2014сальдовая", "оборотно сальдовая"),
("Счёт\u00a060", "счет 60"),
(" Сальдо \t на начало ", "сальдо на начало"),
("Оборотно-\nсальдовая", "оборотно сальдовая"),
("сальдо\u00adвая", "сальдовая"),
("Д-т", "д т"),
("", ""),
(" ", ""),
],
)
def test_normalize_text(raw: str, expected: str) -> None:
assert normalize_text(raw) == expected
# --- Pattern ---
def test_pattern_matches_normalized_text() -> None:
pattern = Pattern.compile("оборотно сальдовая ведомость")
assert pattern.search("ОБОРОТНО-САЛЬДОВАЯ\nВедомость по счету 60") is not None
assert pattern.search("Ведомость") is None
def test_pattern_returns_match_object_for_position_arithmetic() -> None:
match = Pattern.compile(r"по\s+счету").search("Оборотно-сальдовая ведомость по счёту 58.03 за 2024 г.")
assert match is not None
# Позиции считаются по нормализованному тексту: match.string — он же
assert match.string[match.start() : match.end()] == "по счету"
assert match.string[match.end() :].strip().startswith("58.03")
def test_search_normalized_matches_prenormalized_text_without_renormalizing() -> None:
pattern = Pattern.compile("оборотно сальдовая ведомость")
normalized = normalize_text("ОБОРОТНО-САЛЬДОВАЯ\nВедомость")
assert pattern.search_normalized(normalized) is not None
# Текст не нормализуется повторно: «сырая» строка с дефисом и заглавными не совпадает
assert pattern.search_normalized("ОБОРОТНО-САЛЬДОВАЯ ведомость") is None
@pytest.mark.parametrize("bad", ["сальдо на начало-периода", "счёт", "сальдо\nна", "[а-я]+"])
def test_pattern_rejects_sources_that_normalization_would_never_match(bad: str) -> None:
with pytest.raises(ValueError, match="нормализ"):
Pattern.compile(bad)
def test_pattern_rejects_invalid_regex() -> None:
with pytest.raises(ValueError, match="регуляр"):
Pattern.compile("(незакрытая")
def test_shipped_patterns_are_written_in_normalized_form(rules: Rules) -> None:
patterns = _all_patterns(rules)
assert len(patterns) > MIN_PATTERNS
offenders = [p.source for p in patterns if any(ch in p.source for ch in FORBIDDEN_IN_PATTERN)]
assert offenders == []
# --- Загрузка ---
def test_load_rules_reads_all_four_dictionaries(rules: Rules) -> None:
assert rules.headers.sheet_markers
assert set(rules.headers.groups) == {"opening", "turnover", "closing"}
assert rules.groups.mapping
assert rules.periods.rules
assert rules.data_types.header
def test_new_synonym_is_a_data_edit_not_a_code_edit(tmp_path: Path) -> None:
# Синоним из слов, которых нет в штатном словаре, добавляется строкой в YAML; код не меняется
target = _copy_rules(tmp_path)
path = target / "osv_headers.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["groups"]["opening"].append("перенос с прошлого периода")
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
loaded = load_rules(target)
assert first_match([(p, True) for p in loaded.headers.groups["opening"]], "Перенос с прошлого периода") is True
assert (
first_match([(p, True) for p in load_rules().headers.groups["opening"]], "Перенос с прошлого периода") is None
)
def test_missing_required_key_raises_runtime_error_with_file_and_key(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "osv_headers.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
del data["groups"]
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError) as error:
load_rules(target)
assert "osv_headers.yaml" in str(error.value)
assert "groups" in str(error.value)
def test_unknown_key_is_rejected_as_probable_typo(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "periods.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["rulez"] = []
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match=r"periods\.yaml.*rulez"):
load_rules(target)
def test_broken_yaml_syntax_raises_runtime_error_with_file(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
(target / "data_types.yaml").write_text("header: [\n - : :", encoding="utf-8")
with pytest.raises(RuntimeError, match=r"data_types\.yaml"):
load_rules(target)
def test_missing_file_raises_runtime_error_with_file(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
(target / "osv_groups.yaml").unlink()
with pytest.raises(RuntimeError, match=r"osv_groups\.yaml"):
load_rules(target)
def test_pattern_with_yo_in_yaml_is_reported_with_file_and_key(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "osv_headers.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["total_row"].append("всё")
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match=r"osv_headers\.yaml.*total_row"):
load_rules(target)
def test_unknown_group_type_is_rejected(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "osv_groups.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["mapping"].append(["^склад", "WAREHOUSE"])
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match=r"osv_groups\.yaml.*mapping"):
load_rules(target)
# --- Заголовки таблицы (§1.0 п. 1–3, §1.1) ---
REAL_GROUP_HEADERS = [
("Сальдо на начало периода", "opening"),
("Обороты за период", "turnover"),
("Сальдо на конец периода", "closing"),
("Нач. сальдо", "opening"),
("Нач.сальдо", "opening"),
("Входящее сальдо", "opening"),
("Обороты", "turnover"),
("Оборот за период", "turnover"),
("Кон. сальдо", "closing"),
("Исходящее сальдо", "closing"),
("Сальдо\nна конец\nпериода", "closing"),
]
@pytest.mark.parametrize(("text", "group"), REAL_GROUP_HEADERS)
def test_group_headers_match_their_group_only(rules: Rules, text: str, group: str) -> None:
matched = {name for name, patterns in rules.headers.groups.items() if any(p.search(text) for p in patterns)}
assert matched == {group}
def test_sheet_title_is_not_a_turnover_header(rules: Rules) -> None:
title = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
assert not any(p.search(title) for p in rules.headers.groups["turnover"])
assert not any(p.search(title) for p in rules.headers.groups["opening"])
assert any(p.search(title) for p in rules.headers.sheet_markers)
def test_initial_balance_synonym_is_not_in_the_default_dictionary(rules: Rules) -> None:
# «Нач. остаток» добавляет сценарий «новый синоним без релиза кода» (R-8): в штатном словаре его быть не должно
for text in ("Нач. остаток", "Начальный остаток", "Остаток на начало периода"):
assert not any(p.search(text) for p in rules.headers.groups["opening"]), text
@pytest.mark.parametrize("text", ["Дебет", "Дт", "Д-т", "дебет, руб."])
def test_debit_synonyms(rules: Rules, text: str) -> None:
assert any(p.search(text) for p in rules.headers.debit)
assert not any(p.search(text) for p in rules.headers.credit)
@pytest.mark.parametrize("text", ["Кредит", "Кт", "К-т"])
def test_credit_synonyms(rules: Rules, text: str) -> None:
assert any(p.search(text) for p in rules.headers.credit)
assert not any(p.search(text) for p in rules.headers.debit)
@pytest.mark.parametrize("text", ["Бухгалтерский баланс", "Форма по ОКУД 0710001", "Отчёт о финансовых результатах"])
def test_statement_markers(rules: Rules, text: str) -> None:
assert any(p.search(text) for p in rules.headers.statement_markers)
assert not any(p.search(text) for p in rules.headers.sheet_markers)
@pytest.mark.parametrize(
("kind", "text"),
[
("account", "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."),
("account", "Оборотно-сальдовая ведомость по счёту 58.03"),
("period", "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."),
("period", "Период: 1 квартал 2026 г."),
("data_type", "Выводимые данные: БУ (данные бухгалтерского учета)"),
("data_type", "Выводимые данные: сумма"),
("currency", "Валюта БУ, НУ: руб."),
("org_params", "Параметры:"),
("subkonto", "Детализация по субсчетам, субконто: Контрагенты, Договоры"),
("subkonto", "Субконто"),
],
)
def test_header_markers(rules: Rules, kind: str, text: str) -> None:
assert any(p.search(text) for p in rules.headers.header_markers[kind]) # type: ignore[index]
def test_account_marker_ends_right_before_the_account_code(rules: Rules) -> None:
text = "Оборотно-сальдовая ведомость по счёту 58.03 за 3 квартал 2024 г."
match = next(m for p in rules.headers.header_markers["account"] if (m := p.search(text)))
assert match.string[match.end() :].lstrip().startswith("58.03")
@pytest.mark.parametrize(
("kind", "text"),
[
("inn", "ИНН"),
("inn", "Контрагенты, ИНН"),
("kpp", "КПП"),
("code", "Код"),
("quantity", "Количество"),
("currency", "Валюта"),
],
)
def test_service_columns(rules: Rules, kind: str, text: str) -> None:
assert any(p.search(text) for p in rules.headers.service_columns[kind])
@pytest.mark.parametrize("text", ["Итого", "ИТОГО", "Итого по счету"])
def test_total_row(rules: Rules, text: str) -> None:
assert any(p.search(text) for p in rules.headers.total_row)
def test_counterparty_named_like_total_is_not_a_total_row(rules: Rules) -> None:
assert not any(p.search("ООО Итого-Сервис Групп") for p in rules.headers.total_row)
# --- Группировки (§1.1) ---
@pytest.mark.parametrize(
("text", "expected"),
[
("Счет", "ACCOUNT"),
("Счета", "ACCOUNT"),
("Субконто", "ACCOUNT"),
("Подразделение", "DEPARTMENT"),
("Подразделения", "DEPARTMENT"),
("Контрагент", "COUNTERPARTY"),
("Контрагенты", "COUNTERPARTY"),
("Контрагенты, ИНН", "COUNTERPARTY"),
("Договор", "CONTRACT"),
("Договоры", "CONTRACT"),
("Договоры контрагентов", "CONTRACT"),
("Номенклатура", "OTHER"),
("Валюта", "OTHER"),
("Статьи затрат", "OTHER"),
("Основные средства", "OTHER"),
("", "OTHER"),
],
)
def test_group_type_mapping(rules: Rules, text: str, expected: str) -> None:
assert rules.groups.classify(text) == expected
@pytest.mark.parametrize(
"text",
[
"Основные средства",
"Номенклатура",
"Статьи затрат",
"Статьи движения денежных средств",
"Валюты",
"Нематериальные активы",
],
)
def test_known_other_groupings_are_listed_explicitly(rules: Rules, text: str) -> None:
# Явная запись OTHER (а не `default`) нужна детектору: строка блока заголовка с таким текстом — метка группировки,
# а не первая строка данных (реальная формулировка «Основные средства» — ОСВ по счету 01)
assert first_match(rules.groups.mapping, text) == "OTHER"
def test_group_mapping_is_ordered_pairs_and_first_match_wins() -> None:
rules = OsvGroupRules.model_validate(
{"mapping": [["^договор", "CONTRACT"], ["договор", "OTHER"]], "default": "ACCOUNT"}
)
assert rules.classify("Договоры") == "CONTRACT"
assert rules.classify("Прочее") == "ACCOUNT"
# --- Периоды (§1.5) ---
# Каждая строка таблицы §1.5 (все перечисленные в ней написания) → (квартал, нарастающий)
PERIOD_TABLE = [
("1 квартал", "Q1", True),
("I кв.", "Q1", True),
("3 месяца", "Q1", True),
("январь-март", "Q1", True),
("01.01–31.03", "Q1", True),
("2 квартал", "Q2", False),
("II кв.", "Q2", False),
("полугодие", "Q2", True),
("1 полугодие", "Q2", True),
("6 месяцев", "Q2", True),
("январь-июнь", "Q2", True),
("3 квартал", "Q3", False),
("III кв.", "Q3", False),
("9 месяцев", "Q3", True),
("январь-сентябрь", "Q3", True),
("4 квартал", "Q4", False),
("IV кв.", "Q4", False),
("год", "Q4", True),
("за 2025 год", "Q4", True),
("12 месяцев", "Q4", True),
("годовая", "Q4", True),
]
@pytest.mark.parametrize(("text", "quarter", "cumulative"), PERIOD_TABLE)
def test_period_table_1_5(rules: Rules, text: str, quarter: str, cumulative: bool) -> None:
rule = rules.periods.match(text)
assert rule is not None, text
assert (rule.quarter, rule.is_cumulative) == (quarter, cumulative)
@pytest.mark.parametrize(
("text", "quarter", "cumulative"),
[
# Реальные формулировки заголовков фикстур (шапка «за …», как в файле)
("Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г.", "Q1", True),
("Оборотно-сальдовая ведомость по счету 60 за 2 квартал 2024 г.", "Q2", False),
("Оборотно-сальдовая ведомость по счету 62 за 3 квартал 2025 г.", "Q3", False),
("Оборотно-сальдовая ведомость по счету 66 за 4 квартал 2024 г.", "Q4", False),
("Оборотно-сальдовая ведомость по счету 01 за 2024 г.", "Q4", True),
("1 квартал 2025 г.", "Q1", True),
("2024 г.", "Q4", True),
("2024", "Q4", True),
# Варианты написания и порядок специфичных правил
("за 9 месяцев 2024 года", "Q3", True),
("за 9 мес. 2024", "Q3", True),
("за 12 месяцев 2024", "Q4", True),
("1-й квартал 2024", "Q1", True),
("2-й квартал 2024", "Q2", False),
("1 кв. 2024", "Q1", True),
("Январь - Июнь 2024 г.", "Q2", True),
("01.01.2025 - 31.03.2025", "Q1", True),
("01.01.2025 - 30.06.2025", "Q2", True),
("01.01.2025 - 30.09.2025", "Q3", True),
("01.01.2025 - 31.12.2025", "Q4", True),
("за 1 полугодие 2025 г.", "Q2", True),
],
)
def test_period_real_and_variant_phrases(rules: Rules, text: str, quarter: str, cumulative: bool) -> None:
rule = rules.periods.match(text)
assert rule is not None, text
assert (rule.quarter, rule.is_cumulative) == (quarter, cumulative)
def test_period_rules_are_ordered_specific_before_general(rules: Rules) -> None:
# «12 месяцев» и «9 месяцев» не должны ловиться более общими правилами (например, «2 месяца»-подобными)
assert rules.periods.match("12 месяцев").quarter == "Q4" # type: ignore[union-attr]
assert rules.periods.match("9 месяцев").quarter == "Q3" # type: ignore[union-attr]
assert rules.periods.match("1 полугодие 2024 г.").quarter == "Q2" # type: ignore[union-attr]
def test_unknown_period_text_gives_none(rules: Rules) -> None:
assert rules.periods.match("за неделю") is None
assert rules.periods.match("") is None
@pytest.mark.parametrize(
("text", "year"),
[("за 1 квартал 2025 г.", "2025"), ("2024 г.", "2024"), ("01.01.2023 - 31.03.2023", "2023")],
)
def test_period_year_pattern(rules: Rules, text: str, year: str) -> None:
match = rules.periods.year.search(text)
assert match is not None
assert match.group(0) == year
def test_period_year_pattern_ignores_numbers_that_are_not_years(rules: Rules) -> None:
assert rules.periods.year.search("счет 60.01 за 1 квартал") is None
assert rules.periods.year.search("ИНН 3528286057") is None
# --- Типы данных (§1.3) ---
@pytest.mark.parametrize(
("text", "expected"),
[
("БУ (данные бухгалтерского учета)", "BU"),
("НУ (данные налогового учета)", "NU"),
("сумма", "SUM"),
("Сумма (в рублях)", "SUM"),
],
)
def test_header_data_type(rules: Rules, text: str, expected: str) -> None:
assert first_match(rules.data_types.header, text) == expected
def test_header_data_type_unknown(rules: Rules) -> None:
assert first_match(rules.data_types.header, "Прочее") is None
@pytest.mark.parametrize(
("text", "expected"),
[("Вал.", "CURRENCY"), ("вал", "CURRENCY"), ("Кол.", "QUANTITY"), ("Количество", "QUANTITY")],
)
def test_row_data_type(rules: Rules, text: str, expected: str) -> None:
assert first_match(rules.data_types.row, text) == expected
@pytest.mark.parametrize("text", ["Валюта расчетов ООО", "Колесо", "60.01", "АО Русский бисквит"])
def test_row_data_type_does_not_fire_on_ordinary_rows(rules: Rules, text: str) -> None:
assert first_match(rules.data_types.row, text) is None
def test_currency_codes_cover_iso_minimum(rules: Rules) -> None:
required = {"RUB", "USD", "EUR", "CNY", "KZT", "BYN", "GBP", "CHF", "JPY", "TRY", "AED"}
assert required <= set(rules.data_types.currency_codes)
assert all(len(code) == ISO_CODE_LENGTH and code.isupper() for code in rules.data_types.currency_codes)
@pytest.mark.parametrize(
("text", "code"),
[("руб.", "RUB"), ("RUB", "RUB"), ("руб", "RUB"), ("Рублей", "RUB"), ("рубли", "RUB"), ("rub", "RUB")],
)
def test_currency_aliases_map_synonyms_to_iso_code(rules: Rules, text: str, code: str) -> None:
assert rules.data_types.currency_code(text) == code
def test_currency_aliases_are_patterns_keyed_by_iso_code(rules: Rules) -> None:
assert set(rules.data_types.currency_aliases) <= set(rules.data_types.currency_codes)
assert all(isinstance(p, Pattern) for ps in rules.data_types.currency_aliases.values() for p in ps)
assert rules.data_types.currency_aliases["RUB"]
@pytest.mark.parametrize("text", ["USD", "usd", "EUR", "CNY"])
def test_currency_code_recognises_bare_iso_code(rules: Rules, text: str) -> None:
assert rules.data_types.currency_code(text) == text.upper()
@pytest.mark.parametrize("text", ["", "Контрагенты", "ABC", "60.01"])
def test_currency_code_unknown(rules: Rules, text: str) -> None:
assert rules.data_types.currency_code(text) is None