feat(statements): словари форм — заголовки и ОКУД, метки шапки, периоды, соотношения §7 как данные

Refs #14

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
This commit is contained in:
Andrey Epifancev 2026-10-06 14:55:07 +04:00
parent ed516d837c
commit d21884e334
6 changed files with 467 additions and 1 deletions

View File

@ -14,6 +14,9 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`.
| `osv_groups.yaml` | `mapping`, `default` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) |
| `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») |
| `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) |
| `statement_headers.yaml` | `form_titles` (`BALANCE` / `INCOME` / `DISCLOSURE`), `okud` (коды ОКУД по типам), `continuation` (маркер страницы-продолжения), `code_caption`, `name_caption`, `explanations_caption`, `column_numbering`, `labels` (метки шапки документа: `organization`, `inn`, `okpo`, `okved`, `okopf`, `okfs`, `okopf_okfs`, `okei`, `units`, `address`, `date`, `okud_label`), `auditor` (строка блока аудитора), `legal_form` (признак названия организации), `line_codes` (диапазоны кодов строк, доля и минимум кодов в колонке) | заголовок и тип формы ББ/ОФР (баланс / отчет о финансовых результатах / пояснения и расшифровки); ОКУД -> тип; признак продолжения страницы; подписи колонок таблицы; метки и структура шапки документа; диапазоны четырёхзначных кодов строк (§12) |
| `statement_periods.yaml` | `months` (названия месяцев, группы `m01…m12`), `on_date` (дата вида «на 31 декабря 2024 г.», группы `d`, `m`, `y`), `numeric_date` (дата вида «31.12.2024», группы `d`, `m`, `y`), `for_months` (период вида «за 9 месяцев 2025 г.», группы `n`, `y`), `for_range` (диапазон месяцев вида «за январь–сентябрь 2025 г.», группы `m`, `y`), `for_year` (год вида «за 2024 г.» или «2024») | текст заголовка колонки или подзаголовка формы ББ/ОФР -> дата конца периода (§12 п. 4) |
| `statement_checks.yaml` | `tolerance` (допуск в единицах формы), `subline_check` (проверка суммы пятизначных подстрок = родитель), `relations` (список соотношений: `form`, `relation` («код = код [+|-] код …»), `strict`, `abs_terms`) | соотношения между кодами строк баланса и отчета о финансовых результатах (§7), сумма баланса = итого активы = итого пассивы и т.д., как данные (новое соотношение = строка в YAML, не код) |
## Как писать паттерны

View File

@ -0,0 +1,22 @@
# Соотношения §7 (05-domain-rules.md), ББ и ОФР. Данные, не код: новое соотношение — строка здесь.
# relation: «<код> = <код> [+|-] <код> …», только четырёхзначные коды; strict: нарушение переводит документ в needs_check
# (false — только предупреждение, ASSUMPTION(OQ-12)); abs_terms: коды, которые 1С выгружает со знаком минус, а 66н — в
# скобках: правая часть считается и как записано, и с этими членами по модулю (design D-7).
tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно)
subline_check: true # «сумма пятизначных подстрок = родитель» — мягкая проверка, генерируется по коду
relations:
- {form: BALANCE, relation: "1100 = 1105 + 1110 + 1120 + 1130 + 1140 + 1150 + 1160 + 1170 + 1180 + 1190", strict: true}
- {form: BALANCE, relation: "1200 = 1210 + 1215 + 1220 + 1230 + 1240 + 1250 + 1260", strict: true}
- {form: BALANCE, relation: "1300 = 1310 + 1320 + 1340 + 1350 + 1360 + 1370", strict: true}
- {form: BALANCE, relation: "1400 = 1410 + 1420 + 1430 + 1450", strict: true}
- {form: BALANCE, relation: "1500 = 1510 + 1520 + 1530 + 1540 + 1550", strict: true}
- {form: BALANCE, relation: "1600 = 1100 + 1200", strict: true}
- {form: BALANCE, relation: "1700 = 1300 + 1400 + 1500", strict: true}
- {form: BALANCE, relation: "1600 = 1700", strict: true}
- {form: INCOME, relation: "2100 = 2110 - 2120", strict: true, abs_terms: ["2120"]}
- {form: INCOME, relation: "2200 = 2100 - 2210 - 2220", strict: true, abs_terms: ["2210", "2220"]}
- {form: INCOME, relation: "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350", strict: true, abs_terms: ["2330", "2350"]}
- {form: INCOME, relation: "2400 = 2300 - 2410 + 2420", strict: false, abs_terms: ["2410"]}
- {form: INCOME, relation: "2400 = 2300 + 2410 + 2460", strict: false}
- {form: INCOME, relation: "2410 = 2411 + 2412", strict: false}
- {form: INCOME, relation: "2500 = 2400 + 2510 + 2520 + 2530", strict: false}

View File

@ -0,0 +1,90 @@
# Словарь форм ББ/ОФР (05-domain-rules.md §12): заголовки форм и ОКУД, маркер продолжения, подписи колонок, метки шапки.
# Правила записи — как в osv_headers.yaml: паттерн ищется в НОРМАЛИЗОВАННОМ тексте (нижний регистр, «е» вместо «ё»,
# дефисы/тире/переносы -> пробел), без учёта регистра; новый синоним = строка в YAML.
# Заголовок формы -> тип документа. Титул «Бухгалтерская отчетность» (НПЗ) типом не является и здесь не перечислен.
form_titles:
BALANCE:
- 'бухгалтерский\s+баланс'
INCOME:
- 'отчет\s+о\s+финансовых\s+результатах'
- 'отчет\s+о\s+прибылях\s+и\s+убытках'
DISCLOSURE:
- 'пояснени\w*\s+к\s+бухгалтерскому\s+балансу'
- '^расшифровк'
- 'отчет\s+об\s+изменениях\s+капитала'
- 'отчет\s+о\s+движении\s+денежных\s+средств'
- 'отчет\s+о\s+целевом\s+использовании'
# Код ОКУД -> тип (ячейка «Форма по ОКУД 0710001» или сам код). Прочие формы 0710xxx — расшифровки (design D-3, решение №2)
okud:
BALANCE:
- '\b0710001\b'
INCOME:
- '\b0710002\b'
DISCLOSURE:
- '\b0710(?!001|002)'
# Маркер страницы-продолжения в 1С-выгрузке: «Форма 0710001 с.2»
continuation:
- '\bформа\s+0710\d{3}\s+с\.?\s*\d+'
# Подписи колонок заголовка таблицы: ячейка целиком
code_caption:
- '^код(ы)?$'
name_caption:
- 'наименование\s+показателя'
explanations_caption:
- '^пояснени'
# Строка нумерации колонок под шапкой («1 | 2 | 3 | 4 | 5»): каждая непустая ячейка — одно-двузначное число
column_numbering: '^\d{1,2}$'
# Метки шапки документа (значение — правее в той же строке либо в следующей строке)
labels:
organization:
- '^организация\b'
inn:
- '^инн\b'
- 'идентификационный\s+номер\s+налогоплательщика'
okpo:
- 'по\s+окпо'
okved:
- 'по\s+оквэд'
okopf:
- 'по\s+окопф\b(?!\s*/)'
okfs:
- 'по\s+окфс'
okopf_okfs:
- 'по\s+окопф\s*/\s*окфс'
okei:
- 'по\s+океи'
units:
- 'единица\s+измерения'
address:
- 'местонахождение\s*\(адрес\)'
- 'адрес\s+в\s+пределах\s+места\s+нахождения'
- '^адрес\b'
date:
- '^(отчетная\s+)?дата\b'
okud_label:
- 'форма\s+по\s+окуд'
# Строки блока аудитора: ИНН ниже этой метки — ИНН аудитора, не организации
auditor:
- 'аудитор'
# Признак, что строка содержит название организации (для имени над меткой «Организация», НПЗ)
legal_form:
- '\b(ооо|ао|пао|зао|оао|нао|ип|гуп|муп|фгуп)\b'
- '"'
- '«'
# Коды строк форм: диапазоны четырёхзначных кодов (пятизначные — по первым четырём цифрам), доля кодов в колонке,
# минимум кодов, чтобы колонка считалась колонкой кодов
line_codes:
ranges:
BALANCE: [1100, 1799]
INCOME: [2100, 2999]
DISCLOSURE: [5000, 5999]
min_share: 0.6
min_codes: 3

View File

@ -0,0 +1,13 @@
# Периоды форм ББ/ОФР: ячейка заголовка колонки или подзаголовок формы -> дата (конец периода). §12 п. 4.
# Именованные группы обязательны: d (день), m (месяц числом или названием), y (год), n (число месяцев).
months: '(?P<m01>январ)|(?P<m02>феврал)|(?P<m03>март)|(?P<m04>апрел)|(?P<m05>ма[йя])|(?P<m06>июн)|(?P<m07>июл)|(?P<m08>август)|(?P<m09>сентябр)|(?P<m10>октябр)|(?P<m11>ноябр)|(?P<m12>декабр)'
# «На 31 декабря 2024 г.», «на 30 сентября 2025 г.», «31 декабря 2023 года» (без «на» — подзаголовок формы)
on_date: '(?P<d>\d{1,2})\s+(?P<m>январ\w*|феврал\w*|март\w*|апрел\w*|ма[йя]\w*|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)\s+(?P<y>(19|20)\d{2})'
# «31.12.2024», «30.09.2025 г.»
numeric_date: '(?P<d>\d{1,2})\.(?P<m>\d{1,2})\.(?P<y>(19|20)\d{2})'
# «За 9 месяцев 2025 г.», «за 6 мес. 2025»
for_months: '\b(?P<n>\d{1,2})\s*мес\w*\.?\s*(?P<y>(19|20)\d{2})'
# «За Январь - Декабрь 2024 г.», «за январь сентябрь 2025 г.» (дефис уже заменён пробелом)
for_range: '(?P<m1>январ\w*|феврал\w*|март\w*|апрел\w*|ма[йя]\w*|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)\s+(по\s+)?(?P<m>январ\w*|феврал\w*|март\w*|апрел\w*|ма[йя]\w*|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)\s+(?P<y>(19|20)\d{2})'
# «За 2024 г.», «2024 год», ячейка «2024»
for_year: '(^|\bза\s+)(?P<y>(19|20)\d{2})(\s*(г\b|год)|\s*$)'

View File

@ -10,9 +10,12 @@
"""
import bisect
import calendar
import datetime
import re
from collections.abc import Iterable
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from typing import Any, Literal, TypeVar, get_args
@ -31,7 +34,11 @@ __all__ = [
"Pattern",
"PeriodRule",
"PeriodRules",
"RelationRule",
"Rules",
"StatementCheckRules",
"StatementHeaderRules",
"StatementPeriodRules",
"first_match",
"load_rules",
"normalize_text",
@ -44,12 +51,30 @@ HEADERS_FILE = "osv_headers.yaml"
GROUPS_FILE = "osv_groups.yaml"
PERIODS_FILE = "periods.yaml"
DATA_TYPES_FILE = "data_types.yaml"
STATEMENT_HEADERS_FILE = "statement_headers.yaml"
STATEMENT_PERIODS_FILE = "statement_periods.yaml"
STATEMENT_CHECKS_FILE = "statement_checks.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"]
FormType = Literal["BALANCE", "INCOME", "DISCLOSURE"]
StatementLabel = Literal[
"organization",
"inn",
"okpo",
"okved",
"okopf",
"okfs",
"okopf_okfs",
"okei",
"units",
"address",
"date",
"okud_label",
]
_ISO_CODE_LENGTH = 3
# Разделители, сводимые к одному пробелу: любые пробельные (включая NBSP и переносы строк), дефис-минус, дефисы и тире
@ -59,6 +84,9 @@ _SEPARATORS = re.compile(r"[\s\-\u2010-\u2015\u2212]+")
_INVISIBLE = re.compile("[\u00ad\u200b\ufeff]")
# Что нормализация убирает из текста: таких символов в паттерне быть не может
_UNMATCHABLE_IN_PATTERN = re.compile(r"[ёЁ\s\-\u2010-\u2015\u2212\u00ad\u200b\ufeff]")
# Регулярные выражения для парсинга соотношений форм
_RELATION = re.compile(r"^\s*(?P<target>\d{4})\s*=\s*(?P<rhs>\d{4}(\s*[+\-]\s*\d{4})*)\s*$")
_TERM = re.compile(r"([+\-])?\s*(\d{4})")
Value = TypeVar("Value")
@ -295,21 +323,221 @@ class DataTypeRules(_Model):
return None
class _LineCodeRules(_Model):
ranges: dict[FormType, tuple[int, int]]
min_share: float
min_codes: int
@model_validator(mode="after")
def _check(self) -> "_LineCodeRules":
if set(self.ranges) != set(get_args(FormType)):
raise ValueError(f"line_codes.ranges: нужны все формы {get_args(FormType)}")
if not 0 < self.min_share <= 1 or self.min_codes < 1:
raise ValueError("line_codes: min_share в (0, 1], min_codes ≥ 1")
return self
class StatementHeaderRules(_Model):
form_titles: dict[FormType, list[Pattern]]
okud: dict[FormType, list[Pattern]]
continuation: list[Pattern]
code_caption: list[Pattern]
name_caption: list[Pattern]
explanations_caption: list[Pattern]
column_numbering: Pattern
labels: dict[StatementLabel, list[Pattern]]
auditor: list[Pattern]
legal_form: list[Pattern]
line_codes: _LineCodeRules
@model_validator(mode="after")
def _check_required_keys(self) -> "StatementHeaderRules":
for field_name, expected in (
("form_titles", set(get_args(FormType))),
("okud", set(get_args(FormType))),
("labels", set(get_args(StatementLabel))),
):
missing = expected - set(getattr(self, field_name))
if missing:
raise ValueError(f"{field_name}: нет обязательных ключей {sorted(missing)}")
return self
def form_type(self, text: str) -> FormType | None:
entries = ((p, k) for k, patterns in self.form_titles.items() for p in patterns)
return first_match(entries, text)
def okud_type(self, text: str) -> FormType | None:
entries = ((p, k) for k, patterns in self.okud.items() for p in patterns)
return first_match(entries, text)
def is_continuation(self, text: str) -> bool:
return any(pattern.search(text) for pattern in self.continuation)
def is_code_caption(self, text: str) -> bool:
return any(pattern.search(text) for pattern in self.code_caption)
def is_name_caption(self, text: str) -> bool:
return any(pattern.search(text) for pattern in self.name_caption)
def is_explanations_caption(self, text: str) -> bool:
return any(pattern.search(text) for pattern in self.explanations_caption)
def label(self, key: StatementLabel, text: str) -> re.Match[str] | None:
for pattern in self.labels[key]:
found = pattern.search(text)
if found is not None:
return found
return None
def is_auditor_label(self, text: str) -> bool:
return any(pattern.search(text) for pattern in self.auditor)
def has_legal_form(self, text: str) -> bool:
return any(pattern.search(text) for pattern in self.legal_form)
@property
def code_ranges(self) -> dict[FormType, tuple[int, int]]:
return dict(self.line_codes.ranges)
@property
def code_min_share(self) -> float:
return self.line_codes.min_share
@property
def min_codes(self) -> int:
return self.line_codes.min_codes
class StatementPeriodRules(_Model):
months: Pattern
on_date: Pattern
numeric_date: Pattern
for_months: Pattern
for_range: Pattern
for_year: Pattern
@model_validator(mode="after")
def _check_groups(self) -> "StatementPeriodRules":
if self.months.group_names != _MONTH_GROUPS:
raise ValueError("months: ожидаются именованные группы m01…m12")
for name, pattern, groups in (
("on_date", self.on_date, {"d", "m", "y"}),
("numeric_date", self.numeric_date, {"d", "m", "y"}),
("for_months", self.for_months, {"n", "y"}),
("for_range", self.for_range, {"m", "y"}),
("for_year", self.for_year, {"y"}),
):
missing = groups - pattern.group_names
if missing:
raise ValueError(f"{name}: нет именованных групп {sorted(missing)}")
return self
def _month(self, text: str) -> int | None:
if text.isdigit():
number = int(text)
return number if 1 <= number <= _MONTHS_IN_YEAR else None
found = self.months.search(text)
return None if found is None or found.lastgroup is None else int(found.lastgroup.removeprefix("m"))
def date_triple(self, day: str, month: str, year: str) -> datetime.date | None:
"""Дата из трёх ячеек «число | месяц | год» (строки или числа как текст); невалидная — None."""
month_number = self._month(month.strip()) if month.strip() else None
if not day.strip().isdigit() or month_number is None or not year.strip().isdigit():
return None
try:
return datetime.date(int(year), month_number, int(day))
except ValueError:
return None
def period_end(self, text: str) -> datetime.date | None:
"""Дата колонки/подзаголовка: «на <дата>» — она сама; «за N месяцев/за январь–март/за год» — конец периода."""
found = self.on_date.search(text) or self.numeric_date.search(text)
if found is not None:
return self.date_triple(found.group("d"), found.group("m"), found.group("y"))
found = self.for_months.search(text)
if found is not None:
return _month_end(int(found.group("y")), int(found.group("n")))
found = self.for_range.search(text)
if found is not None:
month = self._month(found.group("m"))
return None if month is None else _month_end(int(found.group("y")), month)
found = self.for_year.search(text)
if found is not None:
return datetime.date(int(found.group("y")), _MONTHS_IN_YEAR, 31)
return None
def _month_end(year: int, month: int) -> datetime.date | None:
if not 1 <= month <= _MONTHS_IN_YEAR:
return None
last = calendar.monthrange(year, month)[1]
return datetime.date(year, month, last)
class RelationRule(_Model):
form: FormType
relation: str
strict: bool = True
abs_terms: tuple[str, ...] = ()
@property
def target(self) -> str:
return _parse_relation(self.relation)[0]
@property
def terms(self) -> tuple[tuple[int, str], ...]:
return _parse_relation(self.relation)[1]
@model_validator(mode="after")
def _check(self) -> "RelationRule":
target, terms = _parse_relation(self.relation)
codes = {code for _, code in terms}
unknown = set(self.abs_terms) - codes
if unknown:
raise ValueError(f"abs_terms {sorted(unknown)} нет в правой части «{self.relation}»")
if target in codes:
raise ValueError(f"«{self.relation}»: код слева встречается справа")
return self
def _parse_relation(relation: str) -> tuple[str, tuple[tuple[int, str], ...]]:
found = _RELATION.match(relation)
if found is None:
raise ValueError(f"соотношение «{relation}»: ожидается «<код> = <код> [+|-] <код> …» из четырёхзначных кодов")
terms = tuple((-1 if sign == "-" else 1, code) for sign, code in _TERM.findall(found.group("rhs")))
return found.group("target"), terms
class StatementCheckRules(_Model):
tolerance: Decimal
subline_check: bool = True
relations: list[RelationRule]
def relations_for(self, form: str) -> list[RelationRule]:
return [rule for rule in self.relations if rule.form == form]
@dataclass(frozen=True)
class Rules:
headers: OsvHeaderRules
groups: OsvGroupRules
periods: PeriodRules
data_types: DataTypeRules
statement_headers: StatementHeaderRules
statement_periods: StatementPeriodRules
statement_checks: StatementCheckRules
def load_rules(path: Path = DEFAULT_RULES_DIR) -> Rules:
"""Читает четыре словаря каталога и валидирует их; любая проблема — `RuntimeError` с файлом и ключом."""
"""Читает семь словарей каталога и валидирует их; любая проблема — `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),
statement_headers=_load(path, STATEMENT_HEADERS_FILE, StatementHeaderRules),
statement_periods=_load(path, STATEMENT_PERIODS_FILE, StatementPeriodRules),
statement_checks=_load(path, STATEMENT_CHECKS_FILE, StatementCheckRules),
)

View File

@ -0,0 +1,110 @@
"""Словари форм (design D-10): загрузка, обязательные ключи, разбор соотношений, периоды форм."""
import datetime
from decimal import Decimal
from pathlib import Path
import pytest
import yaml
from src.parser.rules import DEFAULT_RULES_DIR, STATEMENT_CHECKS_FILE, Rules, load_rules
_CODE_MIN_SHARE = 0.6
_MIN_CODES = 3
@pytest.fixture(scope="module")
def rules() -> Rules:
return load_rules()
def test_form_type_by_title_and_okud(rules: Rules) -> None:
headers = rules.statement_headers
assert headers.form_type("Бухгалтерский баланс") == "BALANCE"
assert headers.form_type("ОТЧЕТ О ФИНАНСОВЫХ РЕЗУЛЬТАТАХ") == "INCOME"
assert headers.form_type("Отчет о прибылях и убытках") == "INCOME"
assert headers.form_type("Пояснения к бухгалтерскому балансу") == "DISCLOSURE"
assert headers.form_type("Бухгалтерская отчетность") is None # титул — не тип
assert headers.okud_type("Форма по ОКУД 0710001") == "BALANCE"
assert headers.okud_type("0710002") == "INCOME"
assert headers.okud_type("0710004") == "DISCLOSURE"
assert headers.okud_type("0710001") == "BALANCE"
def test_continuation_marker_and_captions(rules: Rules) -> None:
headers = rules.statement_headers
assert headers.is_continuation("Форма 0710001 с.2")
assert headers.is_continuation("Форма 0710002 с. 2")
assert not headers.is_continuation("Бухгалтерский баланс")
assert headers.is_code_caption("Код") and headers.is_code_caption("Коды")
assert not headers.is_code_caption("Код строки по ОКУД 0710001")
assert headers.is_name_caption("Наименование показателя")
assert headers.is_explanations_caption("Пояснения")
assert headers.code_ranges == {"BALANCE": (1100, 1799), "INCOME": (2100, 2999), "DISCLOSURE": (5000, 5999)}
assert headers.code_min_share == _CODE_MIN_SHARE and headers.min_codes == _MIN_CODES
def test_header_labels(rules: Rules) -> None:
headers = rules.statement_headers
assert headers.label("organization", "Организация:") is not None
assert headers.label("organization", "Организация (орган исполнительной власти)") is not None
assert headers.label("inn", "Идентификационный номер налогоплательщика") is not None
assert headers.label("inn", "ИНН") is not None
assert headers.label("units", "Единица измерения:") is not None
assert headers.label("date", "Дата (число, месяц, год)") is not None
assert headers.label("date", "Отчетная дата\n(число, месяц, год)") is not None
assert headers.label("address", "Местонахождение (адрес)") is not None
assert headers.label("address", "Адрес в пределах места нахождения") is not None
assert headers.label("okopf_okfs", "по ОКОПФ / ОКФС") is not None
assert headers.is_auditor_label("Наименование аудиторской организации")
assert headers.has_legal_form('ООО "НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД"') and not headers.has_legal_form("Коды")
@pytest.mark.parametrize(
("text", "expected"),
[
("На 31 декабря 2024 г.", datetime.date(2024, 12, 31)),
("на 30 сентября 2025 г.", datetime.date(2025, 9, 30)),
("На 31 декабря \n2023 года", datetime.date(2023, 12, 31)),
("31.12.2024", datetime.date(2024, 12, 31)),
("За 9 месяцев 2025 г.", datetime.date(2025, 9, 30)),
("За 9 месяцев\n2025 года", datetime.date(2025, 9, 30)),
("За Январь - Декабрь 2024 г.", datetime.date(2024, 12, 31)),
("за январь - сентябрь 2025 г.", datetime.date(2025, 9, 30)),
("За 2024 г.", datetime.date(2024, 12, 31)),
("2024", datetime.date(2024, 12, 31)),
("Изменение, %", None),
("Пояснения", None),
],
)
def test_period_end(rules: Rules, text: str, expected: datetime.date | None) -> None:
assert rules.statement_periods.period_end(text) == expected
def test_date_triple(rules: Rules) -> None:
assert rules.statement_periods.date_triple("31", "12", "2024") == datetime.date(2024, 12, 31)
assert rules.statement_periods.date_triple("30", "9", "2025") == datetime.date(2025, 9, 30)
assert rules.statement_periods.date_triple("", "12", "2024") is None
def test_relations_parsed_into_terms(rules: Rules) -> None:
checks = rules.statement_checks
balance = {rule.relation: rule for rule in checks.relations_for("BALANCE")}
rule = balance["1600 = 1100 + 1200"]
assert rule.strict and rule.target == "1600" and rule.terms == ((1, "1100"), (1, "1200"))
income = {rule.relation: rule for rule in checks.relations_for("INCOME")}
gross = income["2100 = 2110 - 2120"]
assert gross.terms == ((1, "2110"), (-1, "2120")) and gross.abs_terms == ("2120",)
assert not income["2400 = 2300 - 2410 + 2420"].strict
assert checks.tolerance == Decimal("1")
assert checks.relations_for("DISCLOSURE") == []
def test_bad_relation_rejected(tmp_path: Path) -> None:
for name in DEFAULT_RULES_DIR.glob("*.yaml"):
(tmp_path / name.name).write_bytes(name.read_bytes())
broken = yaml.safe_load((tmp_path / STATEMENT_CHECKS_FILE).read_text(encoding="utf-8"))
broken["relations"].append({"form": "BALANCE", "relation": "1600 = 1100 +", "strict": True})
(tmp_path / STATEMENT_CHECKS_FILE).write_text(yaml.safe_dump(broken, allow_unicode=True), encoding="utf-8")
with pytest.raises(RuntimeError, match="statement_checks.yaml"):
load_rules(tmp_path)