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:
parent
533e5a2a4a
commit
393d65208f
16
api/rules/ocr_lookalikes.yaml
Normal file
16
api/rules/ocr_lookalikes.yaml
Normal 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"
|
||||
6
api/rules/statement_labels.yaml
Normal file
6
api/rules/statement_labels.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
# Нормализованные (нижний регистр, не-буквенно-цифровое → пробел) подстроки label таблицы → тип документа (§11 / D-6).
|
||||
labels:
|
||||
"бухгалтерский баланс": BALANCE
|
||||
"отчет о финансовых результатах": INCOME
|
||||
"отчёт о финансовых результатах": INCOME
|
||||
"отчет о прибылях и убытках": INCOME
|
||||
76
api/rules/statement_lines.yaml
Normal file
76
api/rules/statement_lines.yaml
Normal 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: {}
|
||||
@ -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
139
api/src/normalizer/rules.py
Normal 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)
|
||||
0
api/tests/unit/normalizer/__init__.py
Normal file
0
api/tests/unit/normalizer/__init__.py
Normal file
115
api/tests/unit/normalizer/test_rules.py
Normal file
115
api/tests/unit/normalizer/test_rules.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user