Andrey Epifancev 393d65208f feat(normalizer): справочники yaml с редакциями форм, загрузчик правил, SCAN_CLEANUP/SCAN_RULES_DIR (D-7)
Справочники нормализатора OCR:
- api/rules/statement_lines.yaml: редакции форм ББ/ОФР (66n до 2024-12-31, fsbu4 с 2025-01-01)
- api/rules/ocr_lookalikes.yaml: замены букв на цифры (из date_utils._LOOKALIKES)
- api/rules/statement_labels.yaml: подстроки label → тип документа (BALANCE/INCOME)

Модуль rules.py:
- Edition: редакция с интервалом дат и таблицей кодов → наименований
- OcrRules: все справочники + методы edition_for(), edition_by_codes(), known_codes(), editions_with()
- load_rules(): загрузчик с валидацией (коды 4–5 цифр, двойники 1→1, файлы обязательны)

Настройки:
- SCAN_CLEANUP: удаление файлов из горячей папки (по умолчанию false)
- SCAN_RULES_DIR: путь к справочникам (по умолчанию 'rules')

TDD: 10 тестов (TestRealRules, TestEditionChoice, TestValidation, test_settings_defaults)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-06 23:33:54 +04:00

116 lines
4.9 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.

"""Справочники нормализатора (spec «Справочники нормализации — данные с редакциями форм», D-7)."""
from datetime import date
from pathlib import Path
import pytest
import yaml
from src.core.config import get_settings
from src.normalizer.rules import Edition, OcrRules, load_rules
API_DIR = Path(__file__).resolve().parents[3]
RULES_DIR = API_DIR / "rules"
def _write_rules(tmp_path: Path, lines: dict | None = None) -> Path:
(tmp_path / "statement_lines.yaml").write_text(
yaml.safe_dump(
lines
or {
"editions": [
{"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "Основные средства"}},
{"id": "fsbu4", "valid_from": "2025-01-01", "lines": {}},
]
},
allow_unicode=True,
),
encoding="utf-8",
)
(tmp_path / "ocr_lookalikes.yaml").write_text(yaml.safe_dump({"digits": {"О": "0", "l": "1"}}), encoding="utf-8")
(tmp_path / "statement_labels.yaml").write_text(
yaml.safe_dump({"labels": {"бухгалтерский баланс": "BALANCE"}}, allow_unicode=True), encoding="utf-8"
)
return tmp_path
class TestRealRules:
def test_loads_repository_rules(self) -> None:
rules = load_rules(RULES_DIR)
ids = [edition.id for edition in rules.editions]
assert ids == ["66n", "fsbu4"]
assert rules.editions[0].lines["1150"] == "Основные средства"
assert rules.editions[0].lines["1120"] == "Результаты исследований и разработок"
assert rules.lookalikes["О"] == "0"
assert rules.labels["бухгалтерский баланс"] == "BALANCE"
assert rules.labels["отчет о финансовых результатах"] == "INCOME"
def test_codes_are_four_or_five_digits_without_duplicates(self) -> None:
rules = load_rules(RULES_DIR)
for edition in rules.editions:
codes = list(edition.lines)
assert all(code.isdigit() and 4 <= len(code) <= 5 for code in codes), edition.id
assert len(codes) == len(set(codes)), edition.id
assert all(name.strip() for name in edition.lines.values()), edition.id
def test_fsbu4_edition_exists_even_if_empty(self) -> None:
rules = load_rules(RULES_DIR)
fsbu4 = next(edition for edition in rules.editions if edition.id == "fsbu4")
assert fsbu4.valid_from == date(2025, 1, 1)
class TestEditionChoice:
def test_by_report_date(self, tmp_path: Path) -> None:
rules = load_rules(_write_rules(tmp_path))
assert rules.edition_for(date(2024, 12, 31)).id == "66n"
assert rules.edition_for(date(2025, 6, 30)).id == "fsbu4"
assert rules.edition_for(None) is None
def test_by_codes_prefers_more_hits(self, tmp_path: Path) -> None:
rules = load_rules(
_write_rules(
tmp_path,
{
"editions": [
{"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "ОС", "1110": "НМА"}},
{"id": "fsbu4", "valid_from": "2025-01-01", "lines": {"1150": "ОС", "1231": "ДЗ долгосрочная"}},
]
},
)
)
assert rules.edition_by_codes(["1150", "1231"]).id == "fsbu4"
assert rules.edition_by_codes(["1150"]).id == "66n" # равенство — первая
assert rules.edition_by_codes(["9999"]) is None
def test_known_codes_and_editions_with(self, tmp_path: Path) -> None:
rules = load_rules(_write_rules(tmp_path))
assert rules.known_codes() == frozenset({"1150"})
assert rules.editions_with("1150") == ["66n"]
assert rules.editions_with("1110") == []
class TestValidation:
def test_bad_code_fails_with_file_name(self, tmp_path: Path) -> None:
_write_rules(tmp_path, {"editions": [{"id": "66n", "lines": {"11A0": "x"}}]})
with pytest.raises(ValueError, match="statement_lines.yaml"):
load_rules(tmp_path)
def test_missing_file_fails(self, tmp_path: Path) -> None:
_write_rules(tmp_path)
(tmp_path / "ocr_lookalikes.yaml").unlink()
with pytest.raises(ValueError, match="ocr_lookalikes.yaml"):
load_rules(tmp_path)
def test_lookalike_must_map_to_single_digit(self, tmp_path: Path) -> None:
_write_rules(tmp_path)
(tmp_path / "ocr_lookalikes.yaml").write_text(yaml.safe_dump({"digits": {"О": "00"}}), encoding="utf-8")
with pytest.raises(ValueError, match="ocr_lookalikes.yaml"):
load_rules(tmp_path)
def test_settings_defaults() -> None:
settings = get_settings()
assert settings.SCAN_CLEANUP is False
assert settings.SCAN_RULES_DIR == "rules"
assert isinstance(Edition, type) and isinstance(OcrRules, type)