Andrey Epifancev c1288d08fb fix(normalizer): итоги финального ревью — Money, статус при ошибочном файле, пропущенные строки, длина дат, дубли yaml
- C-1 (№30): сумма с более чем двумя знаками после запятой не принимается (amount=null, string_value, is_recognized=false);
  confidence ячеек и шапки приводится к [0, 100], NaN и бесконечность дают 0
- F-A (№28): ошибочный файл набора при успешном понижает статус до needs_check
- F-B: «ИНН не распознан» только когда ИНН не извлечён, иначе «ИНН распознан неуверенно»
- №29: предупреждения «строк с нечисловым кодом пропущено: N» и «строк без кода с суммами пропущено: N»
- I-3 (№31): тексты дат длиннее 200 символов не разбираются и не проходят fix_lookalikes
- T1.3 (№33): загрузчик yaml отклоняет повторяющиеся ключи; тавтологичный тест дублей заменён настоящим
- гигиена: докстринг amounts.py описывает фактическое поведение (все точки удаляются), мёртвые noqa в ocr/__init__.py

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 03:35:10 +04:00

181 lines
8.6 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_with_names(self) -> None:
"""Дубли кодов проверяет загрузчик yaml (`TestDuplicateKeys`): после `safe_load` в словаре их уже не видно."""
rules = load_rules(RULES_DIR)
for edition in rules.editions:
assert all(code.isdigit() and 4 <= len(code) <= 5 for code in edition.lines), 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)
def test_66n_edition_data_integrity(self) -> None:
rules = load_rules(RULES_DIR)
edition_66n = next(edition for edition in rules.editions if edition.id == "66n")
# Проверка точного количества кодов в редакции 66n
assert len(edition_66n.lines) == 63, f"66n должна содержать 63 кода, а содержит {len(edition_66n.lines)}"
# Проверка специфических кодов, упомянутых в брифе (опечатка и нестандартные)
assert edition_66n.lines["1220"] == "Налог на добавленную стоимость по приобретённым ценностям"
assert edition_66n.lines["1105"] == "Гудвил"
assert edition_66n.lines["2411"] == "в т.ч. текущий налог на прибыль"
assert edition_66n.lines["2412"] == "отложенный налог на прибыль"
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)
class TestDuplicateKeys:
"""Решение №33: повторяющийся ключ yaml — ошибка загрузки (иначе `safe_load` молча оставил бы последний)."""
def test_duplicate_line_code_fails_with_file_and_key(self, tmp_path: Path) -> None:
_write_rules(tmp_path)
(tmp_path / "statement_lines.yaml").write_text(
"editions:\n"
" - id: 66n\n"
" lines:\n"
' "1150": "Основные средства"\n'
' "1110": "Нематериальные активы"\n'
' "1150": "Другое наименование"\n',
encoding="utf-8",
)
with pytest.raises(ValueError, match="statement_lines.yaml") as caught:
load_rules(tmp_path)
assert "повторяющийся ключ '1150'" in str(caught.value) and "line 6" in str(caught.value)
def test_duplicate_edition_field_fails(self, tmp_path: Path) -> None:
_write_rules(tmp_path)
(tmp_path / "statement_lines.yaml").write_text(
"editions:\n - id: 66n\n id: other\n lines: {}\n", encoding="utf-8"
)
with pytest.raises(ValueError, match="повторяющийся ключ 'id'"):
load_rules(tmp_path)
def test_duplicate_lookalike_fails(self, tmp_path: Path) -> None:
_write_rules(tmp_path)
(tmp_path / "ocr_lookalikes.yaml").write_text('digits:\n "О": "0"\n "l": "1"\n "О": "6"\n', encoding="utf-8")
with pytest.raises(ValueError, match="ocr_lookalikes.yaml") as caught:
load_rules(tmp_path)
assert "повторяющийся ключ 'О'" in str(caught.value)
def test_duplicate_label_fails(self, tmp_path: Path) -> None:
_write_rules(tmp_path)
(tmp_path / "statement_labels.yaml").write_text(
"labels:\n бухгалтерский баланс: BALANCE\n бухгалтерский баланс: INCOME\n", encoding="utf-8"
)
with pytest.raises(ValueError, match="statement_labels.yaml") as caught:
load_rules(tmp_path)
assert "повторяющийся ключ 'бухгалтерский баланс'" in str(caught.value)
def test_same_code_in_different_editions_is_fine(self, tmp_path: Path) -> None:
_write_rules(
tmp_path,
{
"editions": [
{"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "ОС"}},
{"id": "fsbu4", "valid_from": "2025-01-01", "lines": {"1150": "ОС"}},
]
},
)
assert load_rules(tmp_path).editions_with("1150") == ["66n", "fsbu4"]
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)