feat(rules): словари ОСВ (заголовки, группировки, периоды, типы данных) и загрузчик

Словари api/rules/{osv_headers,osv_groups,periods,data_types}.yaml и src/parser/rules.py
(normalize_text, Pattern, load_rules, pydantic-валидация с RuntimeError по файлу и ключу,
currency_aliases по R-6). AST-линтер координат tests/unit/parser/test_no_coordinates.py
перенесён из Task 12 (R-7, правила R-4/R-5). Тест формулировок реальных ОСВ.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 12:28:46 +04:00
parent c6a768dc70
commit fcd609c633
12 changed files with 1286 additions and 13 deletions

View File

@ -29,9 +29,9 @@ module = ["miniopy_async", "miniopy_async.*"]
follow_imports = "skip"
ignore_missing_imports = true
# Библиотеки чтения Excel: без типов в поставке
# Библиотеки чтения Excel и YAML (словари `rules/*.yaml`): без типов в поставке
[[tool.mypy.overrides]]
module = ["openpyxl", "openpyxl.*", "xlrd", "xlrd.*", "pyxlsb", "pyxlsb.*"]
module = ["openpyxl", "openpyxl.*", "xlrd", "xlrd.*", "pyxlsb", "pyxlsb.*", "yaml", "yaml.*"]
ignore_missing_imports = true
# Строгая типизация парсера (чистые функции над книгой Excel)

View File

@ -1,11 +1,34 @@
# rules/ — словари синонимов парсера
Каталог для словарей `rules/*.yaml`, по которым `tabular-parser` ищет структуру файла по содержимому: заголовки
колонок, группировки, коды строк, названия форм. Словари — **данные, а не код**: новый синоним добавляется строкой в
YAML без правки парсера. Парсер не привязан к координатам (буквы колонок, номера строк, имена листов).
Словари `rules/*.yaml`, по которым `tabular-parser` ищет структуру файла по содержимому: заголовки колонок,
группировки, периоды, типы данных. Словари — **данные, а не код**: новый синоним добавляется строкой в YAML без
правки парсера. Парсер не привязан к координатам (буквы колонок, номера строк, имена листов); это проверяет
AST-линтер `tests/unit/parser/test_no_coordinates.py`.
В Срезе 0 каталог пуст: парсер — заглушка (`NullParser`, код `NOT_IMPLEMENTED`), словарей нет. Первые словари появятся в
Срезе 2 вместе с реализацией разбора ОСВ / ББ / ОФР (см. `docs/context/05-domain-rules.md` §1.0, §12 в репозитории
`docs`).
Путь указан относительно `api/` (рабочего каталога команд сервиса): `api/rules/`. Загрузчик — `src/parser/rules.py`
(`load_rules()`), источник содержимого — `docs/context/05-domain-rules.md` §1.0–§1.5.
Путь указан относительно `api/` (рабочего каталога команд сервиса): `api/rules/`.
| Файл | Что описывает |
|---|---|
| `osv_headers.yaml` | маркер листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; служебные колонки (ИНН, КПП, код, количество, валюта); строка «Итого»; метки шапки (счёт, период, выводимые данные, валюта, параметры, субконто) |
| `osv_groups.yaml` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) |
| `periods.yaml` | текст периода -> `period_quarter` и `is_cumulative` (§1.5); `year` — паттерн года |
| `data_types.yaml` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.»; коды валют ISO 4217; синонимы валюты (`руб.` -> `RUB`) |
## Как писать паттерны
- Паттерн — регулярное выражение Python. Он ищется (`search`) в **нормализованном** тексте ячейки и применяется
без учёта регистра.
- Нормализация текста (`normalize_text`): нижний регистр, «ё» -> «е», дефисы, тире, неразрывные пробелы и переносы
строк -> один пробел, пробелы схлопываются. Пример: `Оборотно-сальдовая\nведомость` -> `оборотно сальдовая ведомость`.
- Поэтому в паттернах **нет «ё», дефисов, тире и диапазонов `[а-я]`**: пишите `оборотно\s*сальдовая`, `д т` (для
«Д-т»), `\w` вместо диапазона. Загрузчик отвергает такой паттерн с указанием файла и ключа.
- Порядок записей в списках `mapping`, `rules` (периоды), `header`, `row` — приоритет: побеждает первое совпадение,
от специфичных правил к общим.
- Ошибка в словаре (нет обязательного ключа, неизвестный ключ, неверный регэксп или тип) — `RuntimeError` при
загрузке с именем файла и ключом.
## Что намеренно не в штатном словаре
Синоним «нач. остаток» / «начальный остаток» для группы `opening` не включён: он нужен, чтобы показать, что новый
формат подключается строкой в YAML (тест сценария «Новый синоним»).

22
api/rules/data_types.yaml Normal file
View File

@ -0,0 +1,22 @@
# Типы данных ОСВ (05-domain-rules.md §1.1, §1.3). Паттерны ищутся в нормализованном тексте (см. osv_headers.yaml).
# Значение после «Выводимые данные:» -> data_type шапки; первый совпавший побеждает.
# ASSUMPTION(OQ-45): НУ принимается как есть, без сверки с БУ.
header:
- ['\bбу\b|бухгалтерск', BU]
- ['\bну\b|налогов', NU]
- ['\bсумм', SUM]
# Строки под контрагентом: «Вал.» и «Кол.» помечают строку как валютную / количественную
row:
- ['^вал\.?$', CURRENCY]
- ['^кол(ичество)?\.?$', QUANTITY]
# Коды валют ISO 4217 (буквенные): строка, целиком равная коду, — валютная; код же — результат `currency_aliases`
currency_codes: [RUB, USD, EUR, CNY, KZT, BYN, GBP, CHF, JPY, TRY, AED]
# Написания валюты в шапке («Валюта БУ, НУ: руб.» / «RUB») -> код ISO; ключ должен быть в currency_codes
currency_aliases:
RUB:
- '\bруб'
- '\brub\b'

10
api/rules/osv_groups.yaml Normal file
View File

@ -0,0 +1,10 @@
# Названия группировок ОСВ -> тип группировки `group_type` (05-domain-rules.md §1.1).
# Паттерны ищутся в нормализованном тексте (см. osv_headers.yaml), первый совпавший побеждает.
mapping:
- ['^(счета?|субконто):?$', ACCOUNT]
- ['^подразделени', DEPARTMENT]
- ['^контрагент', COUNTERPARTY]
- ['^договор', CONTRACT]
# Всё прочее («Валюта», «Номенклатура», «Статьи затрат», «Основные средства», …)
default: OTHER

View File

@ -0,0 +1,79 @@
# Словарь заголовков ОСВ: чем помечены лист, блоки сумм, колонки и служебные строки (05-domain-rules.md §1.0–§1.2).
#
# Правила записи (проверяет загрузчик):
# - паттерн — регулярное выражение Python, ищется (`search`) в НОРМАЛИЗОВАННОМ тексте ячейки и применяется без учёта
# регистра. Нормализация: нижний регистр, «ё» -> «е», дефисы/тире/неразрывные пробелы/переносы строк -> пробел,
# пробелы схлопываются. Поэтому в паттернах нет «ё», дефисов и диапазонов [а-я]: пишите «оборотно сальдовая
# ведомость», «д т», `\w` вместо диапазона;
# - новый синоним = новая строка в нужном списке, без правки кода.
# Лист содержит ОСВ: любая ячейка (первые строки листа) матчит один из паттернов
sheet_markers:
- 'оборотно\s*сальдовая\s+ведомость'
# Листы форм ББ/ОФР (Срез 2b): ОСВ-парсер их пропускает с предупреждением
statement_markers:
- 'бухгалтерский\s+баланс'
- '0710001'
- 'отчет\s+о\s+финансовых\s+результатах'
- '0710002'
# Три группы сумм в заголовке таблицы. «Оборотно сальдовая» (название ведомости) не должно матчить turnover:
# отсюда границы слова `\bоборот(ы)?\b`.
groups:
opening:
- 'сальдо\s+на\s+начало'
- '\bнач(альн\w*)?\.?\s*сальдо'
- 'входящ\w*\s+сальдо'
turnover:
- '\bоборот(ы)?\b'
closing:
- 'сальдо\s+на\s+конец'
- '\bкон(ечн\w*)?\.?\s*сальдо'
- 'исходящ\w*\s+сальдо'
# Подзаголовки сумм под группой («Дебет»/«Кредит», «Дт»/«Кт», «Д-т»/«К-т»): ячейка начинается с синонима
debit:
- '^(дебет|дт|д т)\b'
credit:
- '^(кредит|кт|к т)\b'
# Служебные колонки таблицы по тексту заголовка: ИНН, КПП, код, количество, валюта. Ключ — имя колонки в `extra`
service_columns:
inn:
- '\bинн\b'
kpp:
- '\bкпп\b'
code:
- '^код\b'
quantity:
- '^(количество|кол)\b'
currency:
- '^валюта\b'
# Итоговая строка таблицы (начало текста первой колонки)
total_row:
- '^итого\b'
# Метки шапки документа (область выше заголовка таблицы); ключи фиксированы
header_markers:
# «по счету 60 за …»: код счёта — сразу после совпадения
account:
- '\bпо\s+счету\b'
# вариант 1: «… за 1 квартал 2025 г.», вариант 2: отдельная строка «Период: 1 квартал 2026 г.»
period:
- '\bза\s+'
- '^период\s*:\s*'
# «Выводимые данные: БУ (данные бухгалтерского учета)»
data_type:
- 'выводимые\s+данные'
# «Валюта БУ, НУ: RUB»
currency:
- '\bвалюта\b'
# «Параметры:» — блок над таблицей
org_params:
- '^параметры\b'
# вариант 2: «Детализация по субсчетам, субконто: Контрагенты, Договоры»; в первой колонке заголовка — «Субконто»
subkonto:
- '\bсубконто\b'
- '\bдетализация\b'

23
api/rules/periods.yaml Normal file
View File

@ -0,0 +1,23 @@
# Текст периода -> квартал и признак нарастающего итога (05-domain-rules.md §1.5).
# Порядок важен: первый совпавший паттерн побеждает, правила идут от специфичных к общим («9 месяцев» и
# «12 месяцев» раньше «год», числовые «\b3\s*мес» с границей слова — не ловят «13 месяцев»).
# ASSUMPTION(OQ-02): is_cumulative=false для Q2–Q4 определяется по тексту «N квартал»; ядро может переопределить по
# данным (opening Q3 == opening Q1 -> нарастающая).
rules:
# Кварталы: арабские и римские номера, «1 кв.», «1-й квартал», «I кв.»
- {pattern: '\b(1|i)\s*(й|я|ый|ой)?\s*кв', quarter: Q1, is_cumulative: true}
- {pattern: '\b(2|ii)\s*(й|я|ый|ой)?\s*кв', quarter: Q2, is_cumulative: false}
- {pattern: '\b(3|iii)\s*(й|я|ый|ой)?\s*кв', quarter: Q3, is_cumulative: false}
- {pattern: '\b(4|iv)\s*(й|я|ый|ой)?\s*кв', quarter: Q4, is_cumulative: false}
# Нарастающие периоды: месяцы, диапазон месяцев, диапазон дат «01.01 - 31.03», «с 01.01.2025 по 31.03.2025»
- {pattern: '\b3\s*мес|январ\w*\s+(по\s+)?март|\b01\.01(\.\d{2,4})?(\s*г\.?)?\s+(по\s+)?31\.03', quarter: Q1, is_cumulative: true}
- {pattern: 'полугоди|\b6\s*мес|январ\w*\s+(по\s+)?июн|\b01\.01(\.\d{2,4})?(\s*г\.?)?\s+(по\s+)?30\.06', quarter: Q2, is_cumulative: true}
- {pattern: '\b9\s*мес|январ\w*\s+(по\s+)?сентябр|\b01\.01(\.\d{2,4})?(\s*г\.?)?\s+(по\s+)?30\.09', quarter: Q3, is_cumulative: true}
- {pattern: '\b12\s*мес|январ\w*\s+(по\s+)?декабр|\b01\.01(\.\d{2,4})?(\s*г\.?)?\s+(по\s+)?31\.12|годов|\bгод\b', quarter: Q4, is_cumulative: true}
# Год: «за 2024 г.», «2024 год»; год в конце даты («31.03.2025 г.») годом периода не считается
- {pattern: '(?<![\d.])(19|20)\d{2}\s*(г\b|год)', quarter: Q4, is_cumulative: true}
# В тексте периода только год: «2024», «за 2024»
- {pattern: '^\W*(за\s+)?(19|20)\d{2}\W*$', quarter: Q4, is_cumulative: true}
# Год периода: четыре цифры 19xx/20xx, не часть длинного числа (ИНН, номер)
year: '(?<!\d)(19|20)\d{2}(?!\d)'

239
api/src/parser/rules.py Normal file
View File

@ -0,0 +1,239 @@
"""Словари `rules/*.yaml` и загрузчик (design D-7): синонимы заголовков, группировок, периодов и типов данных.
Словари — единственный носитель синонимов: код парсера только вызывает `Pattern.search` и не знает ни одного слова
из ведомости. Новый синоним — строка в YAML.
Правила нормализации (одни и те же для проверяемого текста и для самих паттернов в YAML):
текст приводится `normalize_text` (нижний регистр, «ё» -> «е», дефисы/тире/неразрывные пробелы/переносы -> пробел,
пробелы схлопнуты), а паттерн компилируется как есть с `re.IGNORECASE`. Поэтому паттерн, содержащий «ё», дефис или
неразрывный пробел, никогда не сработал бы; загрузчик отвергает такие паттерны с указанием файла и ключа.
"""
import re
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, TypeVar, get_args
import yaml
from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
from pydantic_core import core_schema
from src.contract.common import DataType
__all__ = [
"DEFAULT_RULES_DIR",
"DataTypeRules",
"OsvGroupRules",
"OsvHeaderRules",
"Pattern",
"PeriodRule",
"PeriodRules",
"Rules",
"first_match",
"load_rules",
"normalize_text",
]
DEFAULT_RULES_DIR = Path(__file__).resolve().parents[2] / "rules"
HEADERS_FILE = "osv_headers.yaml"
GROUPS_FILE = "osv_groups.yaml"
PERIODS_FILE = "periods.yaml"
DATA_TYPES_FILE = "data_types.yaml"
GroupType = Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"]
SumGroup = Literal["opening", "turnover", "closing"]
HeaderMarker = Literal["account", "period", "data_type", "currency", "org_params", "subkonto"]
RowDataType = Literal["CURRENCY", "QUANTITY"]
Quarter = Literal["Q1", "Q2", "Q3", "Q4"]
_ISO_CODE_LENGTH = 3
# Разделители, сводимые к одному пробелу: любые пробельные (включая NBSP и переносы строк), дефис-минус, дефисы и тире
# U+2010–U+2015, знак минус U+2212
_SEPARATORS = re.compile(r"[\s\-\u2010-\u2015\u2212]+")
# Невидимые символы переноса: мягкий перенос, пробел нулевой ширины, BOM — удаляются, слово не разрывается
_INVISIBLE = re.compile("[\u00ad\u200b\ufeff]")
# Что нормализация убирает из текста: таких символов в паттерне быть не может
_UNMATCHABLE_IN_PATTERN = re.compile(r"[ёЁ\s\-\u2010-\u2015\u2212\u00ad\u200b\ufeff]")
Value = TypeVar("Value")
def normalize_text(s: str) -> str:
"""Нижний регистр, «ё» -> «е», пробельные символы и дефисы/тире -> один пробел, невидимые переносы удалены."""
lowered = _INVISIBLE.sub("", s).lower().replace("ё", "е")
return _SEPARATORS.sub(" ", lowered).strip()
class Pattern:
"""Регулярное выражение словаря: `search` применяется к `normalize_text(text)`, регистр не учитывается."""
__slots__ = ("_regex", "source")
def __init__(self, source: str, regex: re.Pattern[str]) -> None:
self.source = source
self._regex = regex
@classmethod
def compile(cls, source: str) -> "Pattern":
"""Компиляция записи словаря. `ValueError` — пробел/«ё»/дефис в самом паттерне или неверная регулярка."""
if _UNMATCHABLE_IN_PATTERN.search(source.replace(" ", "")):
raise ValueError(
f"паттерн {source!r} не нормализован: «ё», дефисы, тире и переносы строк в паттерне не допускаются "
"(проверяемый текст нормализуется: «е» вместо «ё», пробел вместо дефиса); пишите `\\s`, `\\w`"
)
try:
regex = re.compile(source, re.IGNORECASE)
except re.error as exc:
raise ValueError(f"паттерн {source!r}: некорректное регулярное выражение ({exc})") from exc
return cls(source, regex)
def search(self, text: str) -> re.Match[str] | None:
"""Первое совпадение в нормализованном тексте; позиции `Match` относятся к нормализованной строке."""
return self._regex.search(normalize_text(text))
def __repr__(self) -> str:
return f"Pattern({self.source!r})"
@classmethod
def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> core_schema.CoreSchema:
return core_schema.no_info_after_validator_function(
cls.compile,
core_schema.str_schema(),
serialization=core_schema.plain_serializer_function_ser_schema(lambda pattern: pattern.source),
)
def first_match(entries: Iterable[tuple[Pattern, Value]], text: str) -> Value | None:
"""Значение первой записи словаря, паттерн которой совпал с текстом (порядок записей = приоритет)."""
for pattern, value in entries:
if pattern.search(text):
return value
return None
class _Model(BaseModel):
# extra=forbid: опечатка в ключе словаря — ошибка загрузки, а не молча игнорируемый синоним
model_config = ConfigDict(frozen=True, extra="forbid")
class OsvHeaderRules(_Model):
sheet_markers: list[Pattern]
statement_markers: list[Pattern]
groups: dict[SumGroup, list[Pattern]]
debit: list[Pattern]
credit: list[Pattern]
service_columns: dict[str, list[Pattern]]
total_row: list[Pattern]
header_markers: dict[HeaderMarker, list[Pattern]]
@model_validator(mode="after")
def _check_required_keys(self) -> "OsvHeaderRules":
for field_name, expected in (
("groups", set(get_args(SumGroup))),
("header_markers", set(get_args(HeaderMarker))),
):
missing = expected - set(getattr(self, field_name))
if missing:
raise ValueError(f"{field_name}: нет обязательных ключей {sorted(missing)}")
if "inn" not in self.service_columns:
raise ValueError("service_columns: нет обязательного ключа 'inn'")
return self
class OsvGroupRules(_Model):
mapping: list[tuple[Pattern, GroupType]]
default: GroupType = "OTHER"
def classify(self, text: str) -> GroupType:
"""Тип группировки по названию из шапки; не совпавшее ни с одним паттерном — `default`."""
found = first_match(self.mapping, text)
return self.default if found is None else found
class PeriodRule(_Model):
pattern: Pattern
quarter: Quarter
is_cumulative: bool
class PeriodRules(_Model):
rules: list[PeriodRule]
year: Pattern
def match(self, text: str) -> PeriodRule | None:
"""Первое правило, паттерн которого совпал с текстом периода; порядок в YAML — от специфичных к общим."""
for rule in self.rules:
if rule.pattern.search(text):
return rule
return None
class DataTypeRules(_Model):
header: list[tuple[Pattern, DataType]]
row: list[tuple[Pattern, RowDataType]]
currency_codes: list[str]
currency_aliases: dict[str, list[Pattern]]
@model_validator(mode="after")
def _check_currencies(self) -> "DataTypeRules":
bad = [code for code in self.currency_codes if len(code) != _ISO_CODE_LENGTH or not code.isupper()]
if bad:
raise ValueError(f"currency_codes: ожидаются буквенные коды ISO 4217 в верхнем регистре, получено {bad}")
unknown = sorted(set(self.currency_aliases) - set(self.currency_codes))
if unknown:
raise ValueError(f"currency_aliases: коды {unknown} не перечислены в currency_codes")
return self
def currency_code(self, text: str) -> str | None:
"""Код ISO по тексту: сам код («usd», «RUB») или синоним из `currency_aliases` («руб.»); иначе `None`."""
bare = normalize_text(text).upper()
if bare in self.currency_codes:
return bare
for code, patterns in self.currency_aliases.items():
if any(pattern.search(text) for pattern in patterns):
return code
return None
@dataclass(frozen=True)
class Rules:
headers: OsvHeaderRules
groups: OsvGroupRules
periods: PeriodRules
data_types: DataTypeRules
def load_rules(path: Path = DEFAULT_RULES_DIR) -> Rules:
"""Читает четыре словаря каталога и валидирует их; любая проблема — `RuntimeError` с файлом и ключом."""
return Rules(
headers=_load(path, HEADERS_FILE, OsvHeaderRules),
groups=_load(path, GROUPS_FILE, OsvGroupRules),
periods=_load(path, PERIODS_FILE, PeriodRules),
data_types=_load(path, DATA_TYPES_FILE, DataTypeRules),
)
ModelT = TypeVar("ModelT", bound=BaseModel)
def _load(directory: Path, name: str, model: type[ModelT]) -> ModelT:
file = directory / name
try:
raw = yaml.safe_load(file.read_text(encoding="utf-8"))
except OSError as exc:
raise RuntimeError(f"словарь {name}: файл не прочитан ({file}): {exc.strerror or exc}") from exc
except yaml.YAMLError as exc:
reason = " ".join(str(exc).split())
raise RuntimeError(f"словарь {name}: некорректный YAML ({file}): {reason}") from exc
if not isinstance(raw, dict):
raise RuntimeError(f"словарь {name}: ожидается словарь верхнего уровня ({file})")
try:
return model.model_validate(raw)
except ValidationError as exc:
problems = [
f"словарь {name}: {'.'.join(str(part) for part in error['loc']) or '<корень>'}: {error['msg']}"
for error in exc.errors()
]
raise RuntimeError("\n".join(problems)) from exc

63
api/tests/fixtures/test_rules_real.py vendored Normal file
View File

@ -0,0 +1,63 @@
"""Словари на реальных ОСВ из ../fixtures: формулировки шапки, блоков сумм, группировок и периодов покрыты.
Проверяются сами формулировки файлов (текст ячеек шапки), а не позиции: детекторы Task 4–5 строятся поверх этих же
словарей. Фикстуры — регрессия формулировок, не оракул формата.
"""
from pathlib import Path
from typing import cast
import pytest
from src.contract import FileType
from src.parser.grid import Sheet
from src.parser.readers import read_book
from src.parser.rules import Pattern, Rules, first_match, load_rules
HEAD_ROWS = 30 # шапка и заголовок таблицы — в начале листа
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def _head_texts(sheet: Sheet) -> list[str]:
last = min(sheet.n_rows, HEAD_ROWS)
return [cell.text for row in range(1, last + 1) for _, cell in sheet.row_cells(row) if cell.kind == "text"]
def _after_colon(text: str) -> str:
return text.split(":", 1)[1] if ":" in text else text
@pytest.mark.fixtures
def test_real_osv_phrases_are_covered_by_dictionaries(osv_file: Path, rules: Rules) -> None:
sheets, _ = read_book(osv_file.read_bytes(), cast(FileType, osv_file.suffix.lstrip(".").upper()))
texts = [text for sheet in sheets for text in _head_texts(sheet)]
headers = rules.headers
def any_cell(patterns: list[Pattern]) -> bool:
return any(pattern.search(text) for text in texts for pattern in patterns)
assert any_cell(headers.sheet_markers), "нет заголовка ведомости"
assert not any_cell(headers.statement_markers), "ОСВ не должна выглядеть как форма ББ/ОФР"
for group in headers.groups:
assert any_cell(headers.groups[group]), f"нет блока сумм {group}"
assert any_cell(headers.debit)
assert any_cell(headers.credit)
assert any_cell(headers.header_markers["org_params"])
assert any_cell(headers.header_markers["data_type"])
title = next(text for text in texts if any(p.search(text) for p in headers.sheet_markers))
account = next(m for p in headers.header_markers["account"] if (m := p.search(title)))
assert account.string[account.end() :].strip()[:1].isdigit()
period = next(m for p in headers.header_markers["period"] if (m := p.search(title)))
period_text = period.string[period.end() :]
assert rules.periods.match(period_text) is not None, title
assert rules.periods.year.search(period_text) is not None, title
data_type_line = next(t for t in texts if any(p.search(t) for p in headers.header_markers["data_type"]))
assert first_match(rules.data_types.header, _after_colon(data_type_line)) == "BU"
currency_line = next(t for t in texts if any(p.search(t) for p in headers.header_markers["currency"]))
assert rules.data_types.currency_code(_after_colon(currency_line)) == "RUB"

View File

@ -0,0 +1,307 @@
"""AST-линтер «без координат» (design D-10, ruling R-7): в `src/parser/**` кроме `grid.py` и `readers/` нет адресов
ячеек и букв колонок, имён листов по умолчанию, сравнений с номерами строк/колонок и индексации `cells[(3, 4)]`.
Структуру файла парсер ищет по содержимому (§1.0 `05-domain-rules.md`), синонимы живут в `rules/*.yaml`. Если линтер
падает на коде, нужен не `noqa`, а другой алгоритм (по содержимому) или синоним в словаре.
"""
import ast
import enum
import importlib
import pkgutil
import re
import typing
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
import pytest
from pydantic import BaseModel
import src.contract
import src.parser
PARSER_ROOT = Path(src.parser.__file__).resolve().parent
EXCLUDED_FILES = frozenset({"grid.py"})
EXCLUDED_DIRS = frozenset({"readers"})
# (а) адреса и буквы колонок: «E9», «A», «AB», «XFD1»
ADDRESS_LITERAL = re.compile(r"^[A-Z]{1,3}\d{0,7}$")
ADDRESS_MAX_LENGTH = 4
# (б) имя листа по умолчанию целиком (R-4): сообщения вроде «лист ОСВ без заголовка» разрешены
DEFAULT_SHEET_NAME = re.compile(r"(?i)^(лист|sheet)[\s_]*\d*$")
# (в) сравнение с номером строки/колонки
COORDINATE_NAMES = frozenset({"row", "row_number", "r", "col", "column"})
@dataclass(frozen=True)
class Violation:
path: Path
line: int
message: str
def __str__(self) -> str:
return f"{self.path}:{self.line}: {self.message}"
def contract_values() -> frozenset[str]:
"""Строковые значения контракта: все StrEnum и Literal-аргументы моделей и алиасов `src.contract` (R-5).
Это структура результата («BU», «Q1», «XLSX», коды ошибок), а не координаты; вычисляется программно.
"""
values: set[str] = set()
def walk(annotation: object) -> None:
if typing.get_origin(annotation) is typing.Literal:
values.update(arg for arg in typing.get_args(annotation) if isinstance(arg, str))
return
for arg in typing.get_args(annotation):
walk(arg)
for module_info in pkgutil.iter_modules(src.contract.__path__, prefix=f"{src.contract.__name__}."):
module = importlib.import_module(module_info.name)
for attribute in vars(module).values():
if isinstance(attribute, type) and issubclass(attribute, enum.StrEnum):
values.update(member.value for member in attribute)
elif isinstance(attribute, type) and issubclass(attribute, BaseModel):
for field in attribute.model_fields.values():
walk(field.annotation)
else:
walk(attribute)
return frozenset(values)
def _is_literal_subscript(node: ast.AST) -> bool:
if not isinstance(node, ast.Subscript):
return False
target = node.value
return (isinstance(target, ast.Name) and target.id == "Literal") or (
isinstance(target, ast.Attribute) and target.attr == "Literal"
)
def _annotation_nodes(tree: ast.AST) -> Iterator[ast.AST]:
"""Аннотации аргументов, возвратов, AnnAssign и любые `Literal[...]` (в том числе в алиасах типов)."""
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
arguments = node.args
every_arg = [*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs]
every_arg += [arg for arg in (arguments.vararg, arguments.kwarg) if arg is not None]
for arg in every_arg:
if arg.annotation is not None:
yield arg.annotation
if node.returns is not None:
yield node.returns
elif isinstance(node, ast.AnnAssign):
yield node.annotation
elif _is_literal_subscript(node):
yield node
def _exempt_constants(tree: ast.AST) -> set[int]:
return {
id(inner) for root in _annotation_nodes(tree) for inner in ast.walk(root) if isinstance(inner, ast.Constant)
}
def _is_coordinate_name(node: ast.AST) -> bool:
return (isinstance(node, ast.Name) and node.id in COORDINATE_NAMES) or (
isinstance(node, ast.Attribute) and node.attr in COORDINATE_NAMES
)
def _is_int_constant(node: ast.AST) -> bool:
return isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool)
def _violations_in_tree(path: Path, tree: ast.AST, allowed_strings: frozenset[str]) -> Iterator[Violation]:
exempt = _exempt_constants(tree)
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str) and id(node) not in exempt:
text = node.value
if len(text) <= ADDRESS_MAX_LENGTH and ADDRESS_LITERAL.fullmatch(text) and text not in allowed_strings:
yield Violation(path, node.lineno, f"литерал {text!r} похож на адрес ячейки или букву колонки")
elif DEFAULT_SHEET_NAME.fullmatch(text):
yield Violation(path, node.lineno, f"литерал {text!r} — имя листа по умолчанию")
elif isinstance(node, ast.Compare):
operands = [node.left, *node.comparators]
pairs = zip(operands, operands[1:], strict=False)
if any(
(_is_coordinate_name(left) and _is_int_constant(right))
or (_is_coordinate_name(right) and _is_int_constant(left))
for left, right in pairs
):
yield Violation(path, node.lineno, "сравнение номера строки/колонки с числовой константой")
elif (
isinstance(node, ast.Subscript)
and isinstance(node.slice, ast.Tuple)
and len(node.slice.elts) == 2 # noqa: PLR2004 - пара (строка, колонка)
and all(_is_int_constant(element) for element in node.slice.elts)
):
yield Violation(path, node.lineno, "индексация ячейки константами (строка, колонка)")
def _parser_files(root: Path) -> Iterator[Path]:
for path in sorted(root.rglob("*.py")):
relative = path.relative_to(root)
if path.name in EXCLUDED_FILES and len(relative.parts) == 1:
continue
if relative.parts[0] in EXCLUDED_DIRS and len(relative.parts) > 1:
continue
yield path
def find_violations(root: Path, allowed_strings: frozenset[str] | None = None) -> list[Violation]:
allowed = contract_values() if allowed_strings is None else allowed_strings
found: list[Violation] = []
for path in _parser_files(root):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
found.extend(_violations_in_tree(path, tree, allowed))
return sorted(found, key=lambda violation: (str(violation.path), violation.line))
def _tree(tmp_path: Path, files: dict[str, str]) -> Path:
root = tmp_path / "parser"
for name, code in files.items():
target = root / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(code, encoding="utf-8")
return root
# --- Реальное дерево ---
def test_parser_sources_have_no_coordinates() -> None:
violations = find_violations(PARSER_ROOT)
assert violations == [], "\n".join(str(violation) for violation in violations)
def test_linter_scans_parser_modules_and_skips_grid_and_readers() -> None:
scanned = {path.relative_to(PARSER_ROOT).as_posix() for path in _parser_files(PARSER_ROOT)}
assert "rules.py" in scanned
assert "errors.py" in scanned
assert "grid.py" not in scanned
assert not any(name.startswith("readers/") for name in scanned)
def test_contract_values_are_computed_from_src_contract() -> None:
values = contract_values()
assert {"BU", "NU", "SUM", "CURRENCY", "QUANTITY", "Q1", "Q4", "XLS", "XLSX", "XLSB"} <= values
assert {"ACCOUNT", "COUNTERPARTY", "UNREADABLE", "INVALID_FORMAT"} <= values
# --- Негативные тесты на временном дереве ---
def test_cell_address_literal_is_caught_with_file_and_line(tmp_path: Path) -> None:
root = _tree(tmp_path, {"detect.py": 'x = 1\ntarget = "E9"\n'})
(violation,) = find_violations(root)
assert violation.path == root / "detect.py"
assert [violation.line] == [2]
assert "detect.py:2" in str(violation)
assert "'E9'" in violation.message
@pytest.mark.parametrize("literal", ["A", "AB", "AZ12", "XFD1"])
def test_column_letters_are_caught(tmp_path: Path, literal: str) -> None:
root = _tree(tmp_path, {"detect.py": f'letter = "{literal}"\n'})
assert len(find_violations(root)) == 1
def test_default_sheet_name_literal_is_caught(tmp_path: Path) -> None:
root = _tree(tmp_path, {"detect.py": 'x = "Лист1"\ny = "Sheet 2"\nz = "лист_1"\n'})
assert [violation.line for violation in find_violations(root)] == [1, 2, 3]
def test_error_message_mentioning_sheet_is_allowed(tmp_path: Path) -> None:
root = _tree(tmp_path, {"detect.py": 'message = "лист ОСВ без заголовка"\nother = "листов просмотрено 3"\n'})
assert find_violations(root) == []
@pytest.mark.parametrize("name", ["row", "row_number", "r", "col", "column"])
def test_comparison_of_row_with_number_is_caught(tmp_path: Path, name: str) -> None:
root = _tree(tmp_path, {"detect.py": f"def f({name}):\n if {name} == 5:\n return 1\n"})
(violation,) = find_violations(root)
assert [violation.line] == [2]
def test_comparison_with_attribute_and_reversed_operands_is_caught(tmp_path: Path) -> None:
root = _tree(tmp_path, {"detect.py": "a = item.row < 3\nb = 7 >= header.col\nc = 1 < r < 10\n"})
assert [violation.line for violation in find_violations(root)] == [1, 2, 3]
def test_comparison_of_other_names_and_non_int_is_allowed(tmp_path: Path) -> None:
code = "a = count == 5\nb = row == other_row\nc = row == header.first_row\nd = row is None\ne = row == 'x'\n"
root = _tree(tmp_path, {"detect.py": code})
assert find_violations(root) == []
@pytest.mark.parametrize("subscript", ["cells[(3, 4)]", "cells[3, 4]", "sheet.cells[(1, 1)]"])
def test_constant_cell_subscript_is_caught(tmp_path: Path, subscript: str) -> None:
root = _tree(tmp_path, {"detect.py": f"value = {subscript}\n"})
(violation,) = find_violations(root)
assert violation.line == 1
def test_subscript_with_variable_indexes_is_allowed(tmp_path: Path) -> None:
root = _tree(tmp_path, {"detect.py": "value = cells[(row, col)]\nother = pair[0]\nthird = cells[(r, 1)]\n"})
assert find_violations(root) == []
def test_literal_in_annotations_is_allowed(tmp_path: Path) -> None:
code = (
"from typing import Literal\n"
"Alias = Literal['Q9', 'ZZ']\n"
"def f(x: Literal['Q1', 'Q2'], *rest: Literal['AB']) -> Literal['CD']:\n"
" y: Literal['EF'] = 'EF'\n"
" return y\n"
)
root = _tree(tmp_path, {"detect.py": code})
violations = find_violations(root)
# В аннотациях и Literal — можно; присваиваемое значение 'EF' вне аннотации — нельзя
assert [violation.line for violation in violations] == [4]
def test_contract_enum_value_is_allowed_but_foreign_short_literal_is_not(tmp_path: Path) -> None:
root = _tree(tmp_path, {"detect.py": 'kind = "BU"\nquarter = "Q3"\nstray = "ZQ"\n'})
violations = find_violations(root)
assert [violation.line for violation in violations] == [3]
def test_grid_py_and_readers_are_excluded_but_other_files_are_not(tmp_path: Path) -> None:
root = _tree(
tmp_path,
{
"grid.py": 'x = "A"\nif row == 1:\n pass\n',
"readers/xlsx.py": 'x = "E9"\ny = cells[(1, 1)]\n',
"nested/grid.py": 'x = "A"\n',
"classify.py": 'x = "A"\n',
},
)
violations = find_violations(root)
assert sorted(violation.path.relative_to(root).as_posix() for violation in violations) == [
"classify.py",
"nested/grid.py",
]

View File

@ -0,0 +1,507 @@
"""Словари `rules/*.yaml` и загрузчик: нормализация текста, паттерны, таблица периодов §1.5, группировки §1.1,
типы данных §1.3, диагностика битых словарей."""
import shutil
from dataclasses import fields, is_dataclass
from pathlib import Path
import pytest
import yaml
from pydantic import BaseModel
from src.parser.rules import (
DEFAULT_RULES_DIR,
OsvGroupRules,
Pattern,
Rules,
first_match,
load_rules,
normalize_text,
)
# Символы, которых не должно быть в паттернах словарей: нормализованный текст их не содержит (ё → е, дефисы и
# неразрывные пробелы → пробел), такой паттерн никогда бы не сработал
MIN_PATTERNS = 40 # защита от «пустого» обхода дерева словарей
ISO_CODE_LENGTH = 3
FORBIDDEN_IN_PATTERN = "ёЁ-\u2010\u2011\u2012\u2013\u2014\u2015\u2212\u00a0\n\r\t"
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def _all_patterns(node: object) -> list[Pattern]:
"""Все `Pattern` в дереве словарей (модели, списки, кортежи, словари)."""
if isinstance(node, Pattern):
return [node]
if isinstance(node, BaseModel):
return _all_patterns(list(dict(node).values()))
if is_dataclass(node) and not isinstance(node, type):
return _all_patterns([getattr(node, field.name) for field in fields(node)])
if isinstance(node, dict):
return _all_patterns(list(node.values()))
if isinstance(node, list | tuple):
return [found for item in node for found in _all_patterns(item)]
return []
def _copy_rules(tmp_path: Path) -> Path:
target = tmp_path / "rules"
shutil.copytree(DEFAULT_RULES_DIR, target, ignore=shutil.ignore_patterns("README.md"))
return target
# --- normalize_text ---
@pytest.mark.parametrize(
("raw", "expected"),
[
("Оборотно-сальдовая\nведомость", "оборотно сальдовая ведомость"),
("ОБОРОТНО\u2013САЛЬДОВАЯ", "оборотно сальдовая"),
("Оборотно\u2014сальдовая", "оборотно сальдовая"),
("Счёт\u00a060", "счет 60"),
(" Сальдо \t на начало ", "сальдо на начало"),
("Оборотно-\nсальдовая", "оборотно сальдовая"),
("сальдо\u00adвая", "сальдовая"),
("Д-т", "д т"),
("", ""),
(" ", ""),
],
)
def test_normalize_text(raw: str, expected: str) -> None:
assert normalize_text(raw) == expected
# --- Pattern ---
def test_pattern_matches_normalized_text() -> None:
pattern = Pattern.compile("оборотно сальдовая ведомость")
assert pattern.search("ОБОРОТНО-САЛЬДОВАЯ\nВедомость по счету 60") is not None
assert pattern.search("Ведомость") is None
def test_pattern_returns_match_object_for_position_arithmetic() -> None:
match = Pattern.compile(r"по\s+счету").search("Оборотно-сальдовая ведомость по счёту 58.03 за 2024 г.")
assert match is not None
# Позиции считаются по нормализованному тексту: match.string — он же
assert match.string[match.start() : match.end()] == "по счету"
assert match.string[match.end() :].strip().startswith("58.03")
@pytest.mark.parametrize("bad", ["сальдо на начало-периода", "счёт", "сальдо\nна", "[а-я]+"])
def test_pattern_rejects_sources_that_normalization_would_never_match(bad: str) -> None:
with pytest.raises(ValueError, match="нормализ"):
Pattern.compile(bad)
def test_pattern_rejects_invalid_regex() -> None:
with pytest.raises(ValueError, match="регуляр"):
Pattern.compile("(незакрытая")
def test_shipped_patterns_are_written_in_normalized_form(rules: Rules) -> None:
patterns = _all_patterns(rules)
assert len(patterns) > MIN_PATTERNS
offenders = [p.source for p in patterns if any(ch in p.source for ch in FORBIDDEN_IN_PATTERN)]
assert offenders == []
# --- Загрузка ---
def test_load_rules_reads_all_four_dictionaries(rules: Rules) -> None:
assert rules.headers.sheet_markers
assert set(rules.headers.groups) == {"opening", "turnover", "closing"}
assert rules.groups.mapping
assert rules.periods.rules
assert rules.data_types.header
def test_new_synonym_is_a_data_edit_not_a_code_edit(tmp_path: Path) -> None:
# Синоним из слов, которых нет в штатном словаре, добавляется строкой в YAML; код не меняется
target = _copy_rules(tmp_path)
path = target / "osv_headers.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["groups"]["opening"].append("перенос с прошлого периода")
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
loaded = load_rules(target)
assert first_match([(p, True) for p in loaded.headers.groups["opening"]], "Перенос с прошлого периода") is True
assert (
first_match([(p, True) for p in load_rules().headers.groups["opening"]], "Перенос с прошлого периода") is None
)
def test_missing_required_key_raises_runtime_error_with_file_and_key(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "osv_headers.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
del data["groups"]
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError) as error:
load_rules(target)
assert "osv_headers.yaml" in str(error.value)
assert "groups" in str(error.value)
def test_unknown_key_is_rejected_as_probable_typo(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "periods.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["rulez"] = []
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match=r"periods\.yaml.*rulez"):
load_rules(target)
def test_broken_yaml_syntax_raises_runtime_error_with_file(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
(target / "data_types.yaml").write_text("header: [\n - : :", encoding="utf-8")
with pytest.raises(RuntimeError, match=r"data_types\.yaml"):
load_rules(target)
def test_missing_file_raises_runtime_error_with_file(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
(target / "osv_groups.yaml").unlink()
with pytest.raises(RuntimeError, match=r"osv_groups\.yaml"):
load_rules(target)
def test_pattern_with_yo_in_yaml_is_reported_with_file_and_key(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "osv_headers.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["total_row"].append("всё")
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match=r"osv_headers\.yaml.*total_row"):
load_rules(target)
def test_unknown_group_type_is_rejected(tmp_path: Path) -> None:
target = _copy_rules(tmp_path)
path = target / "osv_groups.yaml"
data = yaml.safe_load(path.read_text(encoding="utf-8"))
data["mapping"].append(["^склад", "WAREHOUSE"])
path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match=r"osv_groups\.yaml.*mapping"):
load_rules(target)
# --- Заголовки таблицы (§1.0 п. 1–3, §1.1) ---
REAL_GROUP_HEADERS = [
("Сальдо на начало периода", "opening"),
("Обороты за период", "turnover"),
("Сальдо на конец периода", "closing"),
("Нач. сальдо", "opening"),
("Нач.сальдо", "opening"),
("Входящее сальдо", "opening"),
("Обороты", "turnover"),
("Оборот за период", "turnover"),
("Кон. сальдо", "closing"),
("Исходящее сальдо", "closing"),
("Сальдо\nна конец\nпериода", "closing"),
]
@pytest.mark.parametrize(("text", "group"), REAL_GROUP_HEADERS)
def test_group_headers_match_their_group_only(rules: Rules, text: str, group: str) -> None:
matched = {name for name, patterns in rules.headers.groups.items() if any(p.search(text) for p in patterns)}
assert matched == {group}
def test_sheet_title_is_not_a_turnover_header(rules: Rules) -> None:
title = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
assert not any(p.search(title) for p in rules.headers.groups["turnover"])
assert not any(p.search(title) for p in rules.headers.groups["opening"])
assert any(p.search(title) for p in rules.headers.sheet_markers)
def test_initial_balance_synonym_is_not_in_the_default_dictionary(rules: Rules) -> None:
# «Нач. остаток» добавляет сценарий «новый синоним без релиза кода» (R-8): в штатном словаре его быть не должно
for text in ("Нач. остаток", "Начальный остаток", "Остаток на начало периода"):
assert not any(p.search(text) for p in rules.headers.groups["opening"]), text
@pytest.mark.parametrize("text", ["Дебет", "Дт", "Д-т", "дебет, руб."])
def test_debit_synonyms(rules: Rules, text: str) -> None:
assert any(p.search(text) for p in rules.headers.debit)
assert not any(p.search(text) for p in rules.headers.credit)
@pytest.mark.parametrize("text", ["Кредит", "Кт", "К-т"])
def test_credit_synonyms(rules: Rules, text: str) -> None:
assert any(p.search(text) for p in rules.headers.credit)
assert not any(p.search(text) for p in rules.headers.debit)
@pytest.mark.parametrize("text", ["Бухгалтерский баланс", "Форма по ОКУД 0710001", "Отчёт о финансовых результатах"])
def test_statement_markers(rules: Rules, text: str) -> None:
assert any(p.search(text) for p in rules.headers.statement_markers)
assert not any(p.search(text) for p in rules.headers.sheet_markers)
@pytest.mark.parametrize(
("kind", "text"),
[
("account", "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."),
("account", "Оборотно-сальдовая ведомость по счёту 58.03"),
("period", "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."),
("period", "Период: 1 квартал 2026 г."),
("data_type", "Выводимые данные: БУ (данные бухгалтерского учета)"),
("data_type", "Выводимые данные: сумма"),
("currency", "Валюта БУ, НУ: руб."),
("org_params", "Параметры:"),
("subkonto", "Детализация по субсчетам, субконто: Контрагенты, Договоры"),
("subkonto", "Субконто"),
],
)
def test_header_markers(rules: Rules, kind: str, text: str) -> None:
assert any(p.search(text) for p in rules.headers.header_markers[kind]) # type: ignore[index]
def test_account_marker_ends_right_before_the_account_code(rules: Rules) -> None:
text = "Оборотно-сальдовая ведомость по счёту 58.03 за 3 квартал 2024 г."
match = next(m for p in rules.headers.header_markers["account"] if (m := p.search(text)))
assert match.string[match.end() :].lstrip().startswith("58.03")
@pytest.mark.parametrize(
("kind", "text"),
[
("inn", "ИНН"),
("inn", "Контрагенты, ИНН"),
("kpp", "КПП"),
("code", "Код"),
("quantity", "Количество"),
("currency", "Валюта"),
],
)
def test_service_columns(rules: Rules, kind: str, text: str) -> None:
assert any(p.search(text) for p in rules.headers.service_columns[kind])
@pytest.mark.parametrize("text", ["Итого", "ИТОГО", "Итого по счету"])
def test_total_row(rules: Rules, text: str) -> None:
assert any(p.search(text) for p in rules.headers.total_row)
def test_counterparty_named_like_total_is_not_a_total_row(rules: Rules) -> None:
assert not any(p.search("ООО Итого-Сервис Групп") for p in rules.headers.total_row)
# --- Группировки (§1.1) ---
@pytest.mark.parametrize(
("text", "expected"),
[
("Счет", "ACCOUNT"),
("Счета", "ACCOUNT"),
("Субконто", "ACCOUNT"),
("Подразделение", "DEPARTMENT"),
("Подразделения", "DEPARTMENT"),
("Контрагент", "COUNTERPARTY"),
("Контрагенты", "COUNTERPARTY"),
("Контрагенты, ИНН", "COUNTERPARTY"),
("Договор", "CONTRACT"),
("Договоры", "CONTRACT"),
("Договоры контрагентов", "CONTRACT"),
("Номенклатура", "OTHER"),
("Валюта", "OTHER"),
("Статьи затрат", "OTHER"),
("Основные средства", "OTHER"),
("", "OTHER"),
],
)
def test_group_type_mapping(rules: Rules, text: str, expected: str) -> None:
assert rules.groups.classify(text) == expected
def test_group_mapping_is_ordered_pairs_and_first_match_wins() -> None:
rules = OsvGroupRules.model_validate(
{"mapping": [["^договор", "CONTRACT"], ["договор", "OTHER"]], "default": "ACCOUNT"}
)
assert rules.classify("Договоры") == "CONTRACT"
assert rules.classify("Прочее") == "ACCOUNT"
# --- Периоды (§1.5) ---
# Каждая строка таблицы §1.5 (все перечисленные в ней написания) → (квартал, нарастающий)
PERIOD_TABLE = [
("1 квартал", "Q1", True),
("I кв.", "Q1", True),
("3 месяца", "Q1", True),
("январь-март", "Q1", True),
("01.01–31.03", "Q1", True),
("2 квартал", "Q2", False),
("II кв.", "Q2", False),
("полугодие", "Q2", True),
("1 полугодие", "Q2", True),
("6 месяцев", "Q2", True),
("январь-июнь", "Q2", True),
("3 квартал", "Q3", False),
("III кв.", "Q3", False),
("9 месяцев", "Q3", True),
("январь-сентябрь", "Q3", True),
("4 квартал", "Q4", False),
("IV кв.", "Q4", False),
("год", "Q4", True),
("за 2025 год", "Q4", True),
("12 месяцев", "Q4", True),
("годовая", "Q4", True),
]
@pytest.mark.parametrize(("text", "quarter", "cumulative"), PERIOD_TABLE)
def test_period_table_1_5(rules: Rules, text: str, quarter: str, cumulative: bool) -> None:
rule = rules.periods.match(text)
assert rule is not None, text
assert (rule.quarter, rule.is_cumulative) == (quarter, cumulative)
@pytest.mark.parametrize(
("text", "quarter", "cumulative"),
[
# Реальные формулировки заголовков фикстур (шапка «за …», как в файле)
("Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г.", "Q1", True),
("Оборотно-сальдовая ведомость по счету 60 за 2 квартал 2024 г.", "Q2", False),
("Оборотно-сальдовая ведомость по счету 62 за 3 квартал 2025 г.", "Q3", False),
("Оборотно-сальдовая ведомость по счету 66 за 4 квартал 2024 г.", "Q4", False),
("Оборотно-сальдовая ведомость по счету 01 за 2024 г.", "Q4", True),
("1 квартал 2025 г.", "Q1", True),
("2024 г.", "Q4", True),
("2024", "Q4", True),
# Варианты написания и порядок специфичных правил
("за 9 месяцев 2024 года", "Q3", True),
("за 9 мес. 2024", "Q3", True),
("за 12 месяцев 2024", "Q4", True),
("1-й квартал 2024", "Q1", True),
("2-й квартал 2024", "Q2", False),
("1 кв. 2024", "Q1", True),
("Январь - Июнь 2024 г.", "Q2", True),
("01.01.2025 - 31.03.2025", "Q1", True),
("01.01.2025 - 30.06.2025", "Q2", True),
("01.01.2025 - 30.09.2025", "Q3", True),
("01.01.2025 - 31.12.2025", "Q4", True),
("за 1 полугодие 2025 г.", "Q2", True),
],
)
def test_period_real_and_variant_phrases(rules: Rules, text: str, quarter: str, cumulative: bool) -> None:
rule = rules.periods.match(text)
assert rule is not None, text
assert (rule.quarter, rule.is_cumulative) == (quarter, cumulative)
def test_period_rules_are_ordered_specific_before_general(rules: Rules) -> None:
# «12 месяцев» и «9 месяцев» не должны ловиться более общими правилами (например, «2 месяца»-подобными)
assert rules.periods.match("12 месяцев").quarter == "Q4" # type: ignore[union-attr]
assert rules.periods.match("9 месяцев").quarter == "Q3" # type: ignore[union-attr]
assert rules.periods.match("1 полугодие 2024 г.").quarter == "Q2" # type: ignore[union-attr]
def test_unknown_period_text_gives_none(rules: Rules) -> None:
assert rules.periods.match("за неделю") is None
assert rules.periods.match("") is None
@pytest.mark.parametrize(
("text", "year"),
[("за 1 квартал 2025 г.", "2025"), ("2024 г.", "2024"), ("01.01.2023 - 31.03.2023", "2023")],
)
def test_period_year_pattern(rules: Rules, text: str, year: str) -> None:
match = rules.periods.year.search(text)
assert match is not None
assert match.group(0) == year
def test_period_year_pattern_ignores_numbers_that_are_not_years(rules: Rules) -> None:
assert rules.periods.year.search("счет 60.01 за 1 квартал") is None
assert rules.periods.year.search("ИНН 3528286057") is None
# --- Типы данных (§1.3) ---
@pytest.mark.parametrize(
("text", "expected"),
[
("БУ (данные бухгалтерского учета)", "BU"),
("НУ (данные налогового учета)", "NU"),
("сумма", "SUM"),
("Сумма (в рублях)", "SUM"),
],
)
def test_header_data_type(rules: Rules, text: str, expected: str) -> None:
assert first_match(rules.data_types.header, text) == expected
def test_header_data_type_unknown(rules: Rules) -> None:
assert first_match(rules.data_types.header, "Прочее") is None
@pytest.mark.parametrize(
("text", "expected"),
[("Вал.", "CURRENCY"), ("вал", "CURRENCY"), ("Кол.", "QUANTITY"), ("Количество", "QUANTITY")],
)
def test_row_data_type(rules: Rules, text: str, expected: str) -> None:
assert first_match(rules.data_types.row, text) == expected
@pytest.mark.parametrize("text", ["Валюта расчетов ООО", "Колесо", "60.01", "АО Русский бисквит"])
def test_row_data_type_does_not_fire_on_ordinary_rows(rules: Rules, text: str) -> None:
assert first_match(rules.data_types.row, text) is None
def test_currency_codes_cover_iso_minimum(rules: Rules) -> None:
required = {"RUB", "USD", "EUR", "CNY", "KZT", "BYN", "GBP", "CHF", "JPY", "TRY", "AED"}
assert required <= set(rules.data_types.currency_codes)
assert all(len(code) == ISO_CODE_LENGTH and code.isupper() for code in rules.data_types.currency_codes)
@pytest.mark.parametrize(
("text", "code"),
[("руб.", "RUB"), ("RUB", "RUB"), ("руб", "RUB"), ("Рублей", "RUB"), ("рубли", "RUB"), ("rub", "RUB")],
)
def test_currency_aliases_map_synonyms_to_iso_code(rules: Rules, text: str, code: str) -> None:
assert rules.data_types.currency_code(text) == code
def test_currency_aliases_are_patterns_keyed_by_iso_code(rules: Rules) -> None:
assert set(rules.data_types.currency_aliases) <= set(rules.data_types.currency_codes)
assert all(isinstance(p, Pattern) for ps in rules.data_types.currency_aliases.values() for p in ps)
assert rules.data_types.currency_aliases["RUB"]
@pytest.mark.parametrize("text", ["USD", "usd", "EUR", "CNY"])
def test_currency_code_recognises_bare_iso_code(rules: Rules, text: str) -> None:
assert rules.data_types.currency_code(text) == text.upper()
@pytest.mark.parametrize("text", ["", "Контрагенты", "ABC", "60.01"])
def test_currency_code_unknown(rules: Rules, text: str) -> None:
assert rules.data_types.currency_code(text) is None

View File

@ -181,9 +181,9 @@ def load_rules(path: Path = DEFAULT_RULES_DIR) -> Rules # pydantic-валид
```
Содержимое словарей — по §1.0 п. 1–3, §1.1 (таблица group_type), §1.5 (таблица периодов, включая «I кв.», «полугодие», «9 месяцев», «январь-сентябрь», «год», «12 месяцев», диапазоны дат `01.01–31.03`), §1.3 (Вал./Кол., коды валют ISO 4217 буквенные — минимум RUB, USD, EUR, CNY, KZT, BYN, GBP, CHF, JPY, TRY, AED). Порядок правил периода — от более специфичных к общим («9 месяцев» раньше «месяц», «12 месяцев» раньше «2 месяца»-подобных), задаётся порядком списка, первый матч побеждает; тест §1.5 проверяет каждую строку таблицы.
- [ ] **Step 1: Тесты** `test_rules.py`: `normalize_text("Оборотно-сальдовая\nведомость") == "оборотно сальдовая ведомость"`, `normalize_text("ОБОРОТНО–САЛЬДОВАЯ") `; каждая строка таблицы §1.5 → ожидаемые `quarter`/`is_cumulative`; «Договоры контрагентов» → CONTRACT, «Номенклатура» → OTHER; битый YAML (нет ключа `groups`) → `RuntimeError` с именем файла.
- [ ] **Step 2–4:** падает → словари и загрузчик → `pytest tests/unit/parser/test_rules.py -v`, `make check`.
- [ ] **Step 5: Commit** `feat(rules): словари ОСВ (заголовки, группировки, периоды, типы данных) и загрузчик`.
- [x] **Step 1: Тесты** `test_rules.py`: `normalize_text("Оборотно-сальдовая\nведомость") == "оборотно сальдовая ведомость"`, `normalize_text("ОБОРОТНО–САЛЬДОВАЯ") `; каждая строка таблицы §1.5 → ожидаемые `quarter`/`is_cumulative`; «Договоры контрагентов» → CONTRACT, «Номенклатура» → OTHER; битый YAML (нет ключа `groups`) → `RuntimeError` с именем файла.
- [x] **Step 2–4:** падает → словари и загрузчик → `pytest tests/unit/parser/test_rules.py -v`, `make check`.
- [x] **Step 5: Commit** `feat(rules): словари ОСВ (заголовки, группировки, периоды, типы данных) и загрузчик`.
---

View File

@ -19,7 +19,7 @@
определением по сигнатуре и предупреждением при несоответствии; ошибки → `UnreadableError`/`UnsupportedFormatError`.
Тесты: три читателя на одном синтетическом листе дают одинаковые ячейки; реальные xls/xlsx читаются с outline;
`fixtures`-тест: все 66 файлов читаются < 5 с каждый.
- [ ] 1.4 Словари: `api/rules/{osv_headers,osv_groups,periods,data_types}.yaml` (содержимое по §1.1/§1.5 и синонимам из §1.0),
- [x] 1.4 Словари: `api/rules/{osv_headers,osv_groups,periods,data_types}.yaml` (содержимое по §1.1/§1.5 и синонимам из §1.0),
`src/parser/rules.py:load_rules(path) -> Rules` (pydantic, компиляция регулярок с нормализацией ё/е и пробелов),
тест на падение при битом словаре. Покрытие словарей — задача 4.3.