diff --git a/api/pyproject.toml b/api/pyproject.toml index 334461b..28a74f1 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -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) diff --git a/api/rules/README.md b/api/rules/README.md index e58ac19..c2d33c7 100644 --- a/api/rules/README.md +++ b/api/rules/README.md @@ -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 (тест сценария «Новый синоним»). diff --git a/api/rules/data_types.yaml b/api/rules/data_types.yaml new file mode 100644 index 0000000..6d49572 --- /dev/null +++ b/api/rules/data_types.yaml @@ -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' diff --git a/api/rules/osv_groups.yaml b/api/rules/osv_groups.yaml new file mode 100644 index 0000000..88354f1 --- /dev/null +++ b/api/rules/osv_groups.yaml @@ -0,0 +1,10 @@ +# Названия группировок ОСВ -> тип группировки `group_type` (05-domain-rules.md §1.1). +# Паттерны ищутся в нормализованном тексте (см. osv_headers.yaml), первый совпавший побеждает. +mapping: + - ['^(счета?|субконто):?$', ACCOUNT] + - ['^подразделени', DEPARTMENT] + - ['^контрагент', COUNTERPARTY] + - ['^договор', CONTRACT] + +# Всё прочее («Валюта», «Номенклатура», «Статьи затрат», «Основные средства», …) +default: OTHER diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml new file mode 100644 index 0000000..064e501 --- /dev/null +++ b/api/rules/osv_headers.yaml @@ -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' diff --git a/api/rules/periods.yaml b/api/rules/periods.yaml new file mode 100644 index 0000000..e4dfe51 --- /dev/null +++ b/api/rules/periods.yaml @@ -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: '(? «е», дефисы/тире/неразрывные пробелы/переносы -> пробел, +пробелы схлопнуты), а паттерн компилируется как есть с `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 diff --git a/api/tests/fixtures/test_rules_real.py b/api/tests/fixtures/test_rules_real.py new file mode 100644 index 0000000..cf940be --- /dev/null +++ b/api/tests/fixtures/test_rules_real.py @@ -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" diff --git a/api/tests/unit/parser/test_no_coordinates.py b/api/tests/unit/parser/test_no_coordinates.py new file mode 100644 index 0000000..1b8f48f --- /dev/null +++ b/api/tests/unit/parser/test_no_coordinates.py @@ -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", + ] diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py new file mode 100644 index 0000000..c12b6a6 --- /dev/null +++ b/api/tests/unit/parser/test_rules.py @@ -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 diff --git a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md index fa0348b..5070dd5 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md +++ b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md @@ -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): словари ОСВ (заголовки, группировки, периоды, типы данных) и загрузчик`. --- diff --git a/openspec/changes/slice-2a-osv-parser/tasks.md b/openspec/changes/slice-2a-osv-parser/tasks.md index 368bada..7674340 100644 --- a/openspec/changes/slice-2a-osv-parser/tasks.md +++ b/openspec/changes/slice-2a-osv-parser/tasks.md @@ -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.