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
This commit is contained in:
Andrey Epifancev 2026-10-06 23:33:48 +04:00
parent 533e5a2a4a
commit 393d65208f
7 changed files with 356 additions and 0 deletions

View File

@ -0,0 +1,16 @@
# Буквы и знаки, которые OCR ставит вместо цифр (§11.1 п. 1). Замена — только вплотную к цифре, в несколько проходов.
digits:
"О": "0"
"о": "0"
"O": "0"
"o": "0"
"З": "3"
"з": "3"
"l": "1"
"I": "1"
"|": "1"
"і": "1"
"б": "6"
"S": "5"
"s": "5"
"Ч": "4"

View File

@ -0,0 +1,6 @@
# Нормализованные (нижний регистр, не-буквенно-цифровое → пробел) подстроки label таблицы → тип документа (§11 / D-6).
labels:
"бухгалтерский баланс": BALANCE
"отчет о финансовых результатах": INCOME
"отчёт о финансовых результатах": INCOME
"отчет о прибылях и убытках": INCOME

View File

@ -0,0 +1,76 @@
# Справочник «код строки → наименование показателя» форм ББ/ОФР по редакциям (§11.1 п. 6, D-7).
# Редакция выбирается по дате отчёта документа: valid_until / valid_from — включительно.
# 66n — Приказ Минфина № 66н (отчётность до 2024 года включительно), источник — codes_mapping старого ядра DKB_ARMKA
# (проверен на стенде ИС Сканирование). fsbu4 — ФСБУ 4/2023 (отчётность за 2025 год и позже): заполняет владелец
# по Приложениям 3–4 ФСБУ 4/2023 (черновой перечень кодов — шаблоны fixtures/statements-xlsx/templates/*после 01.01.2025*.xlsx);
# пока пуста, документы 2025 года получают имена из OCR и предупреждение.
editions:
- id: 66n
valid_until: 2024-12-31
lines:
"1105": "Гудвил"
"1100": "Итого по разделу I"
"1110": "Нематериальные активы"
"1120": "Результаты исследований и разработок"
"1130": "Нематериальные поисковые активы"
"1140": "Материальные поисковые активы"
"1150": "Основные средства"
"1160": "Инвестиционная недвижимость"
"1170": "Финансовые вложения"
"1180": "Отложенные налоговые активы"
"1190": "Прочие внеоборотные активы"
"1200": "Итого по разделу II"
"1210": "Запасы"
"1215": "Долгосрочные активы к продаже"
"1220": "Налог на добавленную стоимость по приобретённым ценностями"
"1230": "Дебиторская задолженность"
"1240": "Финансовые вложения (за исключением денежных эквивалентов)"
"1250": "Денежные средства и денежные эквиваленты"
"1260": "Прочие оборотные активы"
"1600": "БАЛАНС"
"1300": "Итого по разделу III"
"1310": "Уставный капитал"
"1320": "Собственные акции, выкупленные у акционеров"
"1330": "Целевые средства"
"1340": "Переоценка внеоборотных активов"
"1350": "Добавочный капитал (без переоценки)"
"1360": "Резервный капитал"
"1370": "Нераспределенная прибыль (непокрытый убыток)"
"1400": "Итого по разделу IV"
"1410": "Заемные средства"
"1420": "Отложенные налоговые обязательства"
"1430": "Оценочные обязательства"
"1450": "Прочие обязательства"
"1500": "Итого по разделу V"
"1510": "Заемные средства"
"1520": "Кредиторская задолженность"
"1530": "Доходы будущих периодов"
"1540": "Оценочные обязательства"
"1550": "Прочие обязательства"
"1700": "БАЛАНС"
"2110": "Выручка"
"2120": "Себестоимость продаж"
"2100": "Валовая прибыль (убыток)"
"2210": "Коммерческие расходы"
"2220": "Управленческие расходы"
"2200": "Прибыль (убыток) от продаж"
"2310": "Доходы от участия в других организациях"
"2320": "Проценты к получению"
"2330": "Проценты к уплате"
"2340": "Прочие доходы"
"2350": "Прочие расходы"
"2300": "Прибыль (убыток) до налогообложения"
"2410": "Налог на прибыль"
"2411": "в т.ч. текущий налог на прибыль"
"2412": "отложенный налог на прибыль"
"2460": "Прочее"
"2400": "Чистая прибыль (убыток)"
"2510": "Результат от переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток) периода"
"2520": "Результат от прочих операций, не включаемый в чистую прибыль (убыток) периода"
"2530": "Налог на прибыль от операций, результат которых не включается в чистую прибыль (убыток) периода"
"2500": "Совокупный финансовый результат периода"
"2900": "Базовая прибыль (убыток) на акцию"
"2910": "Разводненная прибыль (убыток) на акцию"
- id: fsbu4
valid_from: 2025-01-01
lines: {}

View File

@ -74,6 +74,10 @@ class Settings(BaseSettings):
SCAN_SETTLE_SECONDS: NonNegativeFloat = 2
SCAN_TIMEOUT_SECONDS: PositiveFloat = 300 # потолок ожидания ответа ИС (дополнительно к reply_deadline запроса)
SCAN_MAX_INFLIGHT: PositiveInt = 4 # одновременно обрабатываемых заданий
# Очистка горячей папки после прочтения набора ответа (D-2): любое удаление, включая .part в Tmp, только при true.
# На SMB-шаре контура права на удаление не подтверждены — по умолчанию выключено
SCAN_CLEANUP: bool = False
SCAN_RULES_DIR: str = "rules" # справочники нормализатора (yaml), путь от текущего каталога (api/)
model_config = SettingsConfigDict(
extra="ignore",

139
api/src/normalizer/rules.py Normal file
View File

@ -0,0 +1,139 @@
"""Справочники нормализатора OCR (D-7, §11.1 п. 1, 6): yaml в `SCAN_RULES_DIR`, загрузка один раз при старте.
`statement_lines.yaml` — редакции форм (`66n` до 2024-12-31, `fsbu4` с 2025-01-01) с таблицей код → наименование;
`ocr_lookalikes.yaml` — двойники букв и цифр; `statement_labels.yaml` — подстроки `label` таблицы → тип документа.
Битый файл — `ValueError` с именем файла (сервис не стартует).
"""
from collections.abc import Iterable
from datetime import date
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
__all__ = ["Edition", "OcrRules", "load_rules"]
LINES_FILE = "statement_lines.yaml"
LOOKALIKES_FILE = "ocr_lookalikes.yaml"
LABELS_FILE = "statement_labels.yaml"
DocumentKind = Literal["BALANCE", "INCOME"]
class Edition(BaseModel):
"""Редакция форм: интервал дат отчёта (включительно, открытый край — `None`) и таблица код → наименование."""
model_config = ConfigDict(frozen=True, extra="forbid")
id: str = Field(min_length=1)
valid_from: date | None = None
valid_until: date | None = None
lines: dict[str, str] = Field(default_factory=dict)
@field_validator("lines")
@classmethod
def _check_lines(cls, lines: dict[str, str]) -> dict[str, str]:
for code, name in lines.items():
if not (code.isdigit() and 4 <= len(code) <= 5): # noqa: PLR2004 — коды строк форм: 4–5 цифр
raise ValueError(f"код строки {code!r}: ожидаются 4–5 цифр")
if not name.strip():
raise ValueError(f"код строки {code!r}: пустое наименование")
return lines
def covers(self, report_date: date) -> bool:
return (self.valid_from is None or self.valid_from <= report_date) and (
self.valid_until is None or report_date <= self.valid_until
)
class _LinesFile(BaseModel):
model_config = ConfigDict(extra="forbid")
editions: list[Edition] = Field(min_length=1)
@model_validator(mode="after")
def _unique_ids(self) -> "_LinesFile":
ids = [edition.id for edition in self.editions]
if len(ids) != len(set(ids)):
raise ValueError(f"id редакций повторяются: {ids}")
return self
class _LookalikesFile(BaseModel):
model_config = ConfigDict(extra="forbid")
digits: dict[str, str]
@field_validator("digits")
@classmethod
def _single_chars(cls, digits: dict[str, str]) -> dict[str, str]:
for char, digit in digits.items():
if len(char) != 1 or len(digit) != 1 or not digit.isdigit():
raise ValueError(f"двойник {char!r} -> {digit!r}: ожидается один символ и одна цифра")
return digits
class _LabelsFile(BaseModel):
model_config = ConfigDict(extra="forbid")
labels: dict[str, DocumentKind]
class OcrRules(BaseModel):
"""Все справочники нормализатора; передаётся в конвейер параметром."""
model_config = ConfigDict(frozen=True)
editions: list[Edition]
lookalikes: dict[str, str]
labels: dict[str, DocumentKind]
def edition_for(self, report_date: date | None) -> Edition | None:
"""Редакция по дате отчёта (первая, чей интервал покрывает дату); `None` без даты или вне интервалов."""
if report_date is None:
return None
return next((edition for edition in self.editions if edition.covers(report_date)), None)
def edition_by_codes(self, codes: Iterable[str]) -> Edition | None:
"""Редакция с наибольшим числом известных кодов; при равенстве — первая; `None`, если совпадений нет."""
wanted = set(codes)
best: Edition | None = None
best_hits = 0
for edition in self.editions:
hits = len(wanted & edition.lines.keys())
if hits > best_hits:
best, best_hits = edition, hits
return best
def known_codes(self) -> frozenset[str]:
return frozenset(code for edition in self.editions for code in edition.lines)
def editions_with(self, code: str) -> list[str]:
return [edition.id for edition in self.editions if code in edition.lines]
def _read_yaml(directory: Path, name: str) -> object:
path = directory / name
try:
text = path.read_text(encoding="utf-8")
except OSError as exc:
raise ValueError(f"справочник {name} не прочитан ({path}): {exc}") from exc
try:
return yaml.safe_load(text)
except yaml.YAMLError as exc:
raise ValueError(f"справочник {name} не разобран как YAML: {exc}") from exc
def load_rules(directory: Path | str) -> OcrRules:
"""Загрузить три справочника из каталога; `ValueError` с именем файла при любой ошибке."""
base = Path(directory)
loaded: dict[str, BaseModel] = {}
for name, model in ((LINES_FILE, _LinesFile), (LOOKALIKES_FILE, _LookalikesFile), (LABELS_FILE, _LabelsFile)):
try:
loaded[name] = model.model_validate(_read_yaml(base, name))
except ValidationError as exc:
raise ValueError(f"справочник {name}: {exc}") from exc
lines = loaded[LINES_FILE]
lookalikes = loaded[LOOKALIKES_FILE]
labels = loaded[LABELS_FILE]
assert isinstance(lines, _LinesFile) and isinstance(lookalikes, _LookalikesFile) # noqa: S101 — сужение типов
assert isinstance(labels, _LabelsFile) # noqa: S101
return OcrRules(editions=lines.editions, lookalikes=lookalikes.digits, labels=labels.labels)

View File

View File

@ -0,0 +1,115 @@
"""Справочники нормализатора (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)