From d21884e3348ecdb2f419d694bca275c788d7117b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 14:55:07 +0400 Subject: [PATCH] =?UTF-8?q?feat(statements):=20=D1=81=D0=BB=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=20=D1=84=D0=BE=D1=80=D0=BC=20=E2=80=94=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D0=BE=D0=BB=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=B8?= =?UTF-8?q?=20=D0=9E=D0=9A=D0=A3=D0=94,=20=D0=BC=D0=B5=D1=82=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B8,=20=D0=BF=D0=B5=D1=80=D0=B8?= =?UTF-8?q?=D0=BE=D0=B4=D1=8B,=20=D1=81=D0=BE=D0=BE=D1=82=D0=BD=D0=BE?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=C2=A77=20=D0=BA=D0=B0=D0=BA?= =?UTF-8?q?=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #14 Co-Authored-By: Claude Haiku 4.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- api/rules/README.md | 3 + api/rules/statement_checks.yaml | 22 ++ api/rules/statement_headers.yaml | 90 +++++++ api/rules/statement_periods.yaml | 13 + api/src/parser/rules.py | 230 +++++++++++++++++- api/tests/unit/parser/test_statement_rules.py | 110 +++++++++ 6 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 api/rules/statement_checks.yaml create mode 100644 api/rules/statement_headers.yaml create mode 100644 api/rules/statement_periods.yaml create mode 100644 api/tests/unit/parser/test_statement_rules.py diff --git a/api/rules/README.md b/api/rules/README.md index 467871a..da667cf 100644 --- a/api/rules/README.md +++ b/api/rules/README.md @@ -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, не код) | ## Как писать паттерны diff --git a/api/rules/statement_checks.yaml b/api/rules/statement_checks.yaml new file mode 100644 index 0000000..7ab146c --- /dev/null +++ b/api/rules/statement_checks.yaml @@ -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} diff --git a/api/rules/statement_headers.yaml b/api/rules/statement_headers.yaml new file mode 100644 index 0000000..a462d0f --- /dev/null +++ b/api/rules/statement_headers.yaml @@ -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 diff --git a/api/rules/statement_periods.yaml b/api/rules/statement_periods.yaml new file mode 100644 index 0000000..e487f59 --- /dev/null +++ b/api/rules/statement_periods.yaml @@ -0,0 +1,13 @@ +# Периоды форм ББ/ОФР: ячейка заголовка колонки или подзаголовок формы -> дата (конец периода). §12 п. 4. +# Именованные группы обязательны: d (день), m (месяц числом или названием), y (год), n (число месяцев). +months: '(?Pянвар)|(?Pфеврал)|(?Pмарт)|(?Pапрел)|(?Pма[йя])|(?Pиюн)|(?Pиюл)|(?Pавгуст)|(?Pсентябр)|(?Pоктябр)|(?Pноябр)|(?Pдекабр)' +# «На 31 декабря 2024 г.», «на 30 сентября 2025 г.», «31 декабря 2023 года» (без «на» — подзаголовок формы) +on_date: '(?P\d{1,2})\s+(?Pянвар\w*|феврал\w*|март\w*|апрел\w*|ма[йя]\w*|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)\s+(?P(19|20)\d{2})' +# «31.12.2024», «30.09.2025 г.» +numeric_date: '(?P\d{1,2})\.(?P\d{1,2})\.(?P(19|20)\d{2})' +# «За 9 месяцев 2025 г.», «за 6 мес. 2025» +for_months: '\b(?P\d{1,2})\s*мес\w*\.?\s*(?P(19|20)\d{2})' +# «За Январь - Декабрь 2024 г.», «за январь сентябрь 2025 г.» (дефис уже заменён пробелом) +for_range: '(?Pянвар\w*|феврал\w*|март\w*|апрел\w*|ма[йя]\w*|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)\s+(по\s+)?(?Pянвар\w*|феврал\w*|март\w*|апрел\w*|ма[йя]\w*|июн\w*|июл\w*|август\w*|сентябр\w*|октябр\w*|ноябр\w*|декабр\w*)\s+(?P(19|20)\d{2})' +# «За 2024 г.», «2024 год», ячейка «2024» +for_year: '(^|\bза\s+)(?P(19|20)\d{2})(\s*(г\b|год)|\s*$)' diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 2017e7c..83b655a 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -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\d{4})\s*=\s*(?P\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), ) diff --git a/api/tests/unit/parser/test_statement_rules.py b/api/tests/unit/parser/test_statement_rules.py new file mode 100644 index 0000000..0c520da --- /dev/null +++ b/api/tests/unit/parser/test_statement_rules.py @@ -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)