period_groups в osv_groups.yaml, признак is_period у ClassifiedRow; родитель, все дети которого периоды: обороты = Σ, начало = начало первого, конец = конец последнего. НКЗ лист 58 убран из манифеста ожидаемых needs_check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
782 lines
33 KiB
Python
782 lines
33 KiB
Python
"""Словари `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_periods_without_end_marker_raises_runtime_error_with_file_and_key(tmp_path: Path) -> None:
|
||
target = _copy_rules(tmp_path)
|
||
path = target / "periods.yaml"
|
||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||
del data["end"]
|
||
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
|
||
|
||
with pytest.raises(RuntimeError) as error:
|
||
load_rules(target)
|
||
|
||
assert "periods.yaml" in str(error.value)
|
||
assert "end" in str(error.value)
|
||
|
||
|
||
def test_default_period_end_marker_cuts_at_year_abbreviation_and_year_word(rules: Rules) -> None:
|
||
end = rules.periods.end
|
||
|
||
assert end.search("3 квартал 2024 г. (по подразделениям)") is not None
|
||
assert end.search("2024 год") is not None
|
||
assert end.search("с 01.01.2025 по 31.03.2025") is None
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("text", "expected"),
|
||
[("январь", 1), ("Мая", 5), ("мая", 5), ("октября", 10), ("Декабрь", 12), ("квартал", None), ("", None)],
|
||
)
|
||
def test_month_number_by_name_in_any_case(rules: Rules, text: str, expected: int | None) -> None:
|
||
assert rules.periods.month_number(text) == expected
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("text", "expected"),
|
||
[
|
||
("Октябрь 2024 г. - Сентябрь 2025 г.", (10, 2024, 9, 2025, 12)),
|
||
("с октября 2024 по сентябрь 2025 г.", (10, 2024, 9, 2025, 12)),
|
||
("апрель - июнь 2025 г.", (4, 2025, 6, 2025, 3)),
|
||
("сентябрь - май 2025", (9, 2024, 5, 2025, 9)),
|
||
("Январь 2025 г. - Сентябрь 2025 г.", (1, 2025, 9, 2025, 9)),
|
||
],
|
||
)
|
||
def test_month_range_with_years(rules: Rules, text: str, expected: tuple[int, int, int, int, int]) -> None:
|
||
found = rules.periods.month_range(text)
|
||
|
||
assert found is not None
|
||
assert (found.start_month, found.start_year, found.end_month, found.end_year, found.months) == expected
|
||
|
||
|
||
@pytest.mark.parametrize("text", ["январь-март 2025 г.", "9 месяцев 2025 г.", "3 квартал 2025 г.", "2025 г.", ""])
|
||
def test_month_range_is_none_for_other_period_texts(rules: Rules, text: str) -> None:
|
||
assert rules.periods.month_range(text) is None
|
||
|
||
|
||
def test_periods_months_without_all_month_groups_is_rejected(tmp_path: Path) -> None:
|
||
target = _copy_rules(tmp_path)
|
||
path = target / "periods.yaml"
|
||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||
data["months"] = "(?P<m01>январ)"
|
||
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
|
||
|
||
with pytest.raises(RuntimeError, match=r"periods\.yaml.*m01"):
|
||
load_rules(target)
|
||
|
||
|
||
def test_periods_range_without_named_groups_is_rejected(tmp_path: Path) -> None:
|
||
target = _copy_rules(tmp_path)
|
||
path = target / "periods.yaml"
|
||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||
data["range"] = "январ"
|
||
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
|
||
|
||
with pytest.raises(RuntimeError, match=r"periods\.yaml.*range"):
|
||
load_rules(target)
|
||
|
||
|
||
def test_inn_pattern_without_named_group_is_rejected(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["counterparty_inn_suffix"] = [r"\d{10}"]
|
||
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
|
||
|
||
with pytest.raises(RuntimeError, match=r"osv_headers\.yaml.*counterparty_inn_suffix"):
|
||
load_rules(target)
|
||
|
||
|
||
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(
|
||
("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", "Субконто"),
|
||
("chart_of_accounts", "за 2 Квартал 2026 г. План счетов ООО Хреновская Нива"),
|
||
],
|
||
)
|
||
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_known_group_is_none_for_default(rules: Rules) -> None:
|
||
assert rules.groups.known("Контрагенты") == "COUNTERPARTY"
|
||
assert rules.groups.known("Материалы") is None
|
||
assert rules.groups.known("Организации") == "COUNTERPARTY"
|
||
assert rules.groups.known("Номенклатура") == "OTHER"
|
||
|
||
|
||
def test_single_organization_is_own_organization_not_counterparty(rules: Rules) -> None:
|
||
"""R-17: «Организация» (единственное число) — собственная организация учёта, не вид субконто «Организации»."""
|
||
assert rules.groups.known("Организация") is None
|
||
assert rules.groups.classify("Организация") == "OTHER"
|
||
|
||
|
||
@pytest.mark.parametrize("text", ["Валюта", "Валюта USD", "В валюте", "в валюте CNY"])
|
||
def test_currency_rows_are_known_other_groups(rules: Rules, text: str) -> None:
|
||
"""R-18: строки валюты словарь групп знает как OTHER — по плану счетов они контрагентами не становятся."""
|
||
assert rules.groups.known(text) == "OTHER"
|
||
|
||
|
||
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", "Контрагенты, ИНН"),
|
||
],
|
||
)
|
||
def test_service_columns(rules: Rules, kind: str, text: str) -> None:
|
||
assert any(p.search(text) for p in rules.headers.service_columns[kind])
|
||
|
||
|
||
def test_service_columns_hold_only_what_the_code_reads(rules: Rules) -> None:
|
||
"""R-22: КПП, код, количество и валюта словаря не требуют — они идут в `extra` под своим заголовком.
|
||
Ключ `indicator` (колонка «Показатели», AURORA-1613) читает код — поэтому он в списке."""
|
||
assert list(rules.headers.service_columns) == ["inn", "indicator"]
|
||
|
||
|
||
@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"),
|
||
("Кол.", "QUANTITY"),
|
||
("Количество", "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
|
||
|
||
|
||
# --- Счета без сальдо по аналитике (osv_checks.yaml, OQ-132) ---
|
||
|
||
|
||
def test_accounts_without_analytic_balance_are_90_91_and_99(rules: Rules) -> None:
|
||
assert rules.checks.accounts_without_analytic_balance == ["90", "91", "99"]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("code", "expected"),
|
||
[
|
||
("91", True),
|
||
("91.01", True),
|
||
(" 91.09 ", True),
|
||
("99", True),
|
||
("99.01.1", True),
|
||
("90", True),
|
||
("90.01", True),
|
||
("90.01.1", True),
|
||
(" 90.09 ", True),
|
||
("900", False),
|
||
("901", False),
|
||
("910", False),
|
||
("9", False),
|
||
("", False),
|
||
(None, False),
|
||
],
|
||
)
|
||
def test_account_without_analytic_balance_is_matched_by_root(rules: Rules, code: str | None, expected: bool) -> None:
|
||
assert rules.checks.has_no_analytic_balance(code) is expected
|
||
|
||
|
||
def test_list_of_accounts_without_analytic_balance_is_a_data_edit(tmp_path: Path) -> None:
|
||
target = _copy_rules(tmp_path)
|
||
path = target / "osv_checks.yaml"
|
||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||
data["accounts_without_analytic_balance"].append("96")
|
||
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
|
||
|
||
assert load_rules(target).checks.has_no_analytic_balance("96.01") is True
|
||
assert load_rules().checks.has_no_analytic_balance("96.01") is False
|
||
|
||
|
||
@pytest.mark.parametrize("bad", ["", "abc", "91.", "9 1"])
|
||
def test_account_code_in_osv_checks_must_be_digits_with_dots(tmp_path: Path, bad: str) -> None:
|
||
target = _copy_rules(tmp_path)
|
||
path = target / "osv_checks.yaml"
|
||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||
data["accounts_without_analytic_balance"] = [bad]
|
||
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
|
||
|
||
with pytest.raises(RuntimeError, match=r"osv_checks\.yaml.*accounts_without_analytic_balance"):
|
||
load_rules(target)
|
||
|
||
|
||
# --- Колонка «Показатели» (§1.3, AURORA-1613) и «В валюте» (AURORA-1661) ---
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("label", "expected"),
|
||
[("БУ", "BU"), ("НУ", "NU"), ("Кол.", "QUANTITY"), ("Кол", "QUANTITY"), ("Вал.", "CURRENCY")],
|
||
)
|
||
def test_indicator_column_labels(rules: Rules, label: str, expected: str) -> None:
|
||
assert first_match(rules.data_types.indicator_column, label) == expected
|
||
|
||
|
||
@pytest.mark.parametrize("label", ["ПР", "ВР"])
|
||
def test_indicator_column_untyped_labels(rules: Rules, label: str) -> None:
|
||
assert first_match(rules.data_types.indicator_column, label) is None
|
||
assert any(pattern.search(label) for pattern in rules.data_types.indicator_column_untyped)
|
||
|
||
|
||
def test_v_valyute_row_is_currency(rules: Rules) -> None:
|
||
assert first_match(rules.data_types.row, "В валюте") == "CURRENCY"
|
||
assert first_match(rules.data_types.row, "Валюта USD") is None
|
||
|
||
|
||
@pytest.mark.parametrize("title", ["Показа-\nтели", "Показатели", "ПОКАЗАТЕЛИ"])
|
||
def test_indicator_service_column(rules: Rules, title: str) -> None:
|
||
assert any(pattern.search(title) for pattern in rules.headers.service_columns["indicator"])
|
||
|
||
|
||
# --- Расчётные счета с контрагентами без названия вида (osv_groups.yaml, #59, R-6) ---
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("code", "expected"),
|
||
[
|
||
("76", True),
|
||
("76.2", True),
|
||
("76.Н.1", True),
|
||
("60.01", True),
|
||
("60", True),
|
||
(" 62.01 ", True),
|
||
("760", False),
|
||
("600", False),
|
||
("10.01", False),
|
||
("71", False),
|
||
("73.01", False),
|
||
("", False),
|
||
(None, False),
|
||
],
|
||
)
|
||
def test_counterparty_accounts_match_code_and_subaccounts(rules: Rules, code: str | None, expected: bool) -> None:
|
||
assert rules.groups.is_counterparty_account(code) is expected
|
||
|
||
|
||
def test_counterparty_accounts_list_is_data(rules: Rules) -> None:
|
||
assert rules.groups.counterparty_accounts == ["58", "60", "62", "66", "67", "75", "76", "80"]
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("name", "expected"),
|
||
[
|
||
("Период", True),
|
||
("период", True),
|
||
(" Период ", True),
|
||
("Периодичность", False),
|
||
("Договоры", False),
|
||
(None, False),
|
||
],
|
||
)
|
||
def test_period_groups_are_data(rules: Rules, name: str | None, expected: bool) -> None:
|
||
"""AURORA-1823: группировка «Период» (строки «Обороты за <месяц>») — из `period_groups` словаря, не из кода."""
|
||
assert rules.groups.is_period_group(name) is expected
|
||
|
||
|
||
def test_period_groups_dictionary_key_is_loaded(rules: Rules) -> None:
|
||
assert [pattern.source for pattern in rules.groups.period_groups] == ["^период$"]
|
||
|
||
|
||
@pytest.mark.parametrize("bad", ["", "abc", "60.", "6 0"])
|
||
def test_counterparty_account_code_must_be_digits_with_dots(tmp_path: Path, bad: str) -> None:
|
||
target = _copy_rules(tmp_path)
|
||
path = target / "osv_groups.yaml"
|
||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||
data["counterparty_accounts"] = [bad]
|
||
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
|
||
|
||
with pytest.raises(RuntimeError, match=r"osv_groups\.yaml.*counterparty_accounts"):
|
||
load_rules(target)
|