arm-tabular-parser/api/tests/unit/parser/test_statement_rules.py
Andrey Epifancev 07d57cbd07 test(statements): манифест реальных форм, property-тест, golden форм, покрытие словарей форм
Манифест statements_manifest.yaml по 18 реальным файлам statements-xlsx (сверен
прогоном парсера, R-13, OQ-137), property-тест по манифесту, снимки форм в
golden_helpers/golden_update/test_golden, словари форм в покрытии, синтетика
st06 для вариантов заголовков. Мёртвые записи словаря удалены (name_caption,
метки okved и okud_label) либо покрыты st06.

Refs #14

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
2026-10-06 20:50:25 +04:00

139 lines
7.7 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.

"""Словари форм (design D-10): загрузка, обязательные ключи, разбор соотношений, периоды форм."""
import datetime
from decimal import Decimal
from pathlib import Path
import pytest
import yaml
from src.parser.rules import DEFAULT_RULES_DIR, STATEMENT_CHECKS_FILE, Rules, load_rules
_CODE_MIN_SHARE = 0.6
_MIN_CODES = 3
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def test_form_type_by_title_and_okud(rules: Rules) -> None:
headers = rules.statement_headers
assert headers.form_type("Бухгалтерский баланс") == "BALANCE"
assert headers.form_type("ОТЧЕТ О ФИНАНСОВЫХ РЕЗУЛЬТАТАХ") == "INCOME"
assert headers.form_type("Отчет о прибылях и убытках") == "INCOME"
assert headers.form_type("Пояснения к бухгалтерскому балансу") == "DISCLOSURE"
assert headers.form_type("Бухгалтерская отчетность") is None # титул — не тип
assert headers.okud_type("Форма по ОКУД 0710001") == "BALANCE"
assert headers.okud_type("0710002") == "INCOME"
assert headers.okud_type("0710004") == "DISCLOSURE"
assert headers.okud_type("0710001") == "BALANCE"
def test_okud_type_boundaries(rules: Rules) -> None:
"""Граничные случаи для кодов OKUD: диапазон 0710003-0710099 для DISCLOSURE."""
headers = rules.statement_headers
# Нижняя граница диапазона DISCLOSURE
assert headers.okud_type("0710003") == "DISCLOSURE"
# Верхняя граница диапазона DISCLOSURE
assert headers.okud_type("0710099") == "DISCLOSURE"
# Середина диапазона
assert headers.okud_type("0710010") == "DISCLOSURE"
assert headers.okud_type("0710050") == "DISCLOSURE"
# За границами диапазона
assert headers.okud_type("0710000") is None # до диапазона
assert headers.okud_type("07101") is None # неполный код
assert headers.okud_type("07100030") is None # слишком длинный
assert headers.okud_type("0710100") is None # за диапазоном
def test_continuation_marker_and_captions(rules: Rules) -> None:
headers = rules.statement_headers
assert headers.is_continuation("Форма 0710001 с.2")
assert headers.is_continuation("Форма 0710002 с. 2")
assert not headers.is_continuation("Бухгалтерский баланс")
assert headers.is_code_caption("Код") and headers.is_code_caption("Коды")
assert not headers.is_code_caption("Код строки по ОКУД 0710001")
assert headers.is_explanations_caption("Пояснения")
assert headers.code_ranges == {"BALANCE": (1100, 1799), "INCOME": (2100, 2999), "DISCLOSURE": (5000, 5999)}
assert headers.code_min_share == _CODE_MIN_SHARE and headers.min_codes == _MIN_CODES
def test_header_labels(rules: Rules) -> None:
headers = rules.statement_headers
assert headers.label("organization", "Организация:") is not None
assert headers.label("organization", "Организация (орган исполнительной власти)") is not None
assert headers.label("inn", "Идентификационный номер налогоплательщика") is not None
assert headers.label("inn", "ИНН") is not None
assert headers.label("units", "Единица измерения:") is not None
assert headers.label("date", "Дата (число, месяц, год)") is not None
assert headers.label("date", "Отчетная дата\n(число, месяц, год)") is not None
assert headers.label("address", "Местонахождение (адрес)") is not None
assert headers.label("address", "Адрес в пределах места нахождения") is not None
assert headers.label("okopf_okfs", "по ОКОПФ / ОКФС") is not None
assert headers.is_auditor_label("Наименование аудиторской организации")
assert headers.has_legal_form('ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"') and not headers.has_legal_form("Коды")
@pytest.mark.parametrize(
("text", "expected"),
[
("На 31 декабря 2024 г.", datetime.date(2024, 12, 31)),
("на 30 сентября 2025 г.", datetime.date(2025, 9, 30)),
("На 31 декабря \n2023 года", datetime.date(2023, 12, 31)),
("31.12.2024", datetime.date(2024, 12, 31)),
("За 9 месяцев 2025 г.", datetime.date(2025, 9, 30)),
("За 9 месяцев\n2025 года", datetime.date(2025, 9, 30)),
("За Январь - Декабрь 2024 г.", datetime.date(2024, 12, 31)),
("за январь - сентябрь 2025 г.", datetime.date(2025, 9, 30)),
("За 2024 г.", datetime.date(2024, 12, 31)),
("2024", datetime.date(2024, 12, 31)),
("Изменение, %", None),
("Пояснения", None),
],
)
def test_period_end(rules: Rules, text: str, expected: datetime.date | None) -> None:
assert rules.statement_periods.period_end(text) == expected
def test_date_triple(rules: Rules) -> None:
assert rules.statement_periods.date_triple("31", "12", "2024") == datetime.date(2024, 12, 31)
assert rules.statement_periods.date_triple("30", "9", "2025") == datetime.date(2025, 9, 30)
assert rules.statement_periods.date_triple("", "12", "2024") is None
def test_relations_parsed_into_terms(rules: Rules) -> None:
checks = rules.statement_checks
balance = {rule.relation: rule for rule in checks.relations_for("BALANCE")}
rule = balance["1600 = 1100 + 1200"]
assert rule.strict and rule.target == "1600" and rule.terms == ((1, "1100"), (1, "1200"))
income = {rule.relation: rule for rule in checks.relations_for("INCOME")}
gross = income["2100 = 2110 - 2120"]
assert gross.terms == ((1, "2110"), (-1, "2120")) and gross.abs_terms == ("2120",)
assert not income["2400 = 2300 - 2410 + 2420"].strict
assert checks.tolerance == Decimal("1")
assert checks.relations_for("DISCLOSURE") == []
def test_bad_relation_rejected(tmp_path: Path) -> None:
for name in DEFAULT_RULES_DIR.glob("*.yaml"):
(tmp_path / name.name).write_bytes(name.read_bytes())
broken = yaml.safe_load((tmp_path / STATEMENT_CHECKS_FILE).read_text(encoding="utf-8"))
broken["relations"].append({"form": "BALANCE", "relation": "1600 = 1100 +", "strict": True})
(tmp_path / STATEMENT_CHECKS_FILE).write_text(yaml.safe_dump(broken, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match="statement_checks.yaml"):
load_rules(tmp_path)
def test_explicit_rules_reject_five_digit_codes(tmp_path: Path) -> None:
"""R-9: явные правила должны содержать только четырёхзначные коды; пятизначные только в подстроках."""
for name in DEFAULT_RULES_DIR.glob("*.yaml"):
(tmp_path / name.name).write_bytes(name.read_bytes())
broken = yaml.safe_load((tmp_path / STATEMENT_CHECKS_FILE).read_text(encoding="utf-8"))
# Пытаемся добавить правило с пятизначным кодом на правой части
broken["relations"].append({"form": "BALANCE", "relation": "1100 = 11101 + 1105", "strict": True})
(tmp_path / STATEMENT_CHECKS_FILE).write_text(yaml.safe_dump(broken, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match="пятизначные коды допустимы только в сгенерированных подстроках"):
load_rules(tmp_path)