Справочники нормализатора 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
116 lines
4.9 KiB
Python
116 lines
4.9 KiB
Python
"""Справочники нормализатора (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)
|