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:
parent
c6a768dc70
commit
fcd609c633
@ -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)
|
||||
|
||||
@ -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
22
api/rules/data_types.yaml
Normal 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
10
api/rules/osv_groups.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
# Названия группировок ОСВ -> тип группировки `group_type` (05-domain-rules.md §1.1).
|
||||
# Паттерны ищутся в нормализованном тексте (см. osv_headers.yaml), первый совпавший побеждает.
|
||||
mapping:
|
||||
- ['^(счета?|субконто):?$', ACCOUNT]
|
||||
- ['^подразделени', DEPARTMENT]
|
||||
- ['^контрагент', COUNTERPARTY]
|
||||
- ['^договор', CONTRACT]
|
||||
|
||||
# Всё прочее («Валюта», «Номенклатура», «Статьи затрат», «Основные средства», …)
|
||||
default: OTHER
|
||||
79
api/rules/osv_headers.yaml
Normal file
79
api/rules/osv_headers.yaml
Normal 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
23
api/rules/periods.yaml
Normal 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
239
api/src/parser/rules.py
Normal 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
63
api/tests/fixtures/test_rules_real.py
vendored
Normal 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"
|
||||
307
api/tests/unit/parser/test_no_coordinates.py
Normal file
307
api/tests/unit/parser/test_no_coordinates.py
Normal 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",
|
||||
]
|
||||
507
api/tests/unit/parser/test_rules.py
Normal file
507
api/tests/unit/parser/test_rules.py
Normal 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
|
||||
@ -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): словари ОСВ (заголовки, группировки, периоды, типы данных) и загрузчик`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user