"""Словари форм (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 _FRACTIONAL_SHARE_PERCENT = 10 @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) def _broken_headers(tmp_path: Path, **changes: object) -> None: for name in DEFAULT_RULES_DIR.glob("*.yaml"): (tmp_path / name.name).write_bytes(name.read_bytes()) path = tmp_path / "statement_headers.yaml" data = yaml.safe_load(path.read_text(encoding="utf-8")) for key, value in changes.items(): if key == "min_codes_continuation": data["line_codes"][key] = value else: data[key] = value path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8") def test_fractional_share_percent_loaded_and_bounded(rules: Rules, tmp_path: Path) -> None: """M-7: порог предупреждения о дробных значениях — из словаря, диапазон 1..100.""" assert rules.statement_headers.fractional_share_percent == _FRACTIONAL_SHARE_PERCENT for bad in (0, 101): _broken_headers(tmp_path, fractional_share_percent=bad) with pytest.raises(RuntimeError, match="fractional_share_percent"): load_rules(tmp_path) def test_min_codes_continuation_loaded_and_bounded(rules: Rules, tmp_path: Path) -> None: """I-1: ослабленный порог листа-продолжения — из словаря, 1 ≤ значение ≤ min_codes.""" assert rules.statement_headers.min_codes_continuation == 1 for bad in (0, _MIN_CODES + 1): _broken_headers(tmp_path, min_codes_continuation=bad) with pytest.raises(RuntimeError, match="min_codes_continuation"): load_rules(tmp_path) def test_units_thousands_and_okud_code_from_dictionary(rules: Rules) -> None: """M-5: слово «тыс» и код ОКУД берутся из словаря, а не из литералов кода.""" headers = rules.statement_headers assert headers.is_thousands("в тыс. рублей") and not headers.is_thousands("руб.") found = headers.okud_code.search("Форма по ОКУД 0710002") assert found is not None and found.group(0) == "0710002"