From 393d65208f9efefce1ef15601217fcd2c422e2f7 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 23:33:48 +0400 Subject: [PATCH] =?UTF-8?q?feat(normalizer):=20=D1=81=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B8=20yaml=20=D1=81=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=86=D0=B8=D1=8F=D0=BC=D0=B8=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC,=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B7=D1=87=D0=B8=D0=BA=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?,=20SCAN=5FCLEANUP/SCAN=5FRULES=5FDIR=20(D-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Справочники нормализатора 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/rules/ocr_lookalikes.yaml | 16 +++ api/rules/statement_labels.yaml | 6 + api/rules/statement_lines.yaml | 76 +++++++++++++ api/src/core/config.py | 4 + api/src/normalizer/rules.py | 139 ++++++++++++++++++++++++ api/tests/unit/normalizer/__init__.py | 0 api/tests/unit/normalizer/test_rules.py | 115 ++++++++++++++++++++ 7 files changed, 356 insertions(+) create mode 100644 api/rules/ocr_lookalikes.yaml create mode 100644 api/rules/statement_labels.yaml create mode 100644 api/rules/statement_lines.yaml create mode 100644 api/src/normalizer/rules.py create mode 100644 api/tests/unit/normalizer/__init__.py create mode 100644 api/tests/unit/normalizer/test_rules.py diff --git a/api/rules/ocr_lookalikes.yaml b/api/rules/ocr_lookalikes.yaml new file mode 100644 index 0000000..f8ea282 --- /dev/null +++ b/api/rules/ocr_lookalikes.yaml @@ -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" diff --git a/api/rules/statement_labels.yaml b/api/rules/statement_labels.yaml new file mode 100644 index 0000000..f77dd71 --- /dev/null +++ b/api/rules/statement_labels.yaml @@ -0,0 +1,6 @@ +# Нормализованные (нижний регистр, не-буквенно-цифровое → пробел) подстроки label таблицы → тип документа (§11 / D-6). +labels: + "бухгалтерский баланс": BALANCE + "отчет о финансовых результатах": INCOME + "отчёт о финансовых результатах": INCOME + "отчет о прибылях и убытках": INCOME diff --git a/api/rules/statement_lines.yaml b/api/rules/statement_lines.yaml new file mode 100644 index 0000000..07764d2 --- /dev/null +++ b/api/rules/statement_lines.yaml @@ -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: {} diff --git a/api/src/core/config.py b/api/src/core/config.py index 00a326e..100a8ea 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -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", diff --git a/api/src/normalizer/rules.py b/api/src/normalizer/rules.py new file mode 100644 index 0000000..9a12d23 --- /dev/null +++ b/api/src/normalizer/rules.py @@ -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) diff --git a/api/tests/unit/normalizer/__init__.py b/api/tests/unit/normalizer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/unit/normalizer/test_rules.py b/api/tests/unit/normalizer/test_rules.py new file mode 100644 index 0000000..92f216f --- /dev/null +++ b/api/tests/unit/normalizer/test_rules.py @@ -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)