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:
parent
ed516d837c
commit
d21884e334
@ -14,6 +14,9 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`.
|
|||||||
| `osv_groups.yaml` | `mapping`, `default` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) |
|
| `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` — конец текста периода в строке шапки («г.», «год») |
|
| `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`) |
|
| `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, не код) |
|
||||||
|
|
||||||
## Как писать паттерны
|
## Как писать паттерны
|
||||||
|
|
||||||
|
|||||||
22
api/rules/statement_checks.yaml
Normal file
22
api/rules/statement_checks.yaml
Normal 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}
|
||||||
90
api/rules/statement_headers.yaml
Normal file
90
api/rules/statement_headers.yaml
Normal 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
|
||||||
13
api/rules/statement_periods.yaml
Normal file
13
api/rules/statement_periods.yaml
Normal 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*$)'
|
||||||
@ -10,9 +10,12 @@
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import bisect
|
import bisect
|
||||||
|
import calendar
|
||||||
|
import datetime
|
||||||
import re
|
import re
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal, TypeVar, get_args
|
from typing import Any, Literal, TypeVar, get_args
|
||||||
|
|
||||||
@ -31,7 +34,11 @@ __all__ = [
|
|||||||
"Pattern",
|
"Pattern",
|
||||||
"PeriodRule",
|
"PeriodRule",
|
||||||
"PeriodRules",
|
"PeriodRules",
|
||||||
|
"RelationRule",
|
||||||
"Rules",
|
"Rules",
|
||||||
|
"StatementCheckRules",
|
||||||
|
"StatementHeaderRules",
|
||||||
|
"StatementPeriodRules",
|
||||||
"first_match",
|
"first_match",
|
||||||
"load_rules",
|
"load_rules",
|
||||||
"normalize_text",
|
"normalize_text",
|
||||||
@ -44,12 +51,30 @@ HEADERS_FILE = "osv_headers.yaml"
|
|||||||
GROUPS_FILE = "osv_groups.yaml"
|
GROUPS_FILE = "osv_groups.yaml"
|
||||||
PERIODS_FILE = "periods.yaml"
|
PERIODS_FILE = "periods.yaml"
|
||||||
DATA_TYPES_FILE = "data_types.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"]
|
GroupType = Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"]
|
||||||
SumGroup = Literal["opening", "turnover", "closing"]
|
SumGroup = Literal["opening", "turnover", "closing"]
|
||||||
HeaderMarker = Literal["account", "period", "data_type", "currency", "org_params", "subkonto"]
|
HeaderMarker = Literal["account", "period", "data_type", "currency", "org_params", "subkonto"]
|
||||||
RowDataType = Literal["CURRENCY", "QUANTITY"]
|
RowDataType = Literal["CURRENCY", "QUANTITY"]
|
||||||
Quarter = Literal["Q1", "Q2", "Q3", "Q4"]
|
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
|
_ISO_CODE_LENGTH = 3
|
||||||
# Разделители, сводимые к одному пробелу: любые пробельные (включая NBSP и переносы строк), дефис-минус, дефисы и тире
|
# Разделители, сводимые к одному пробелу: любые пробельные (включая NBSP и переносы строк), дефис-минус, дефисы и тире
|
||||||
@ -59,6 +84,9 @@ _SEPARATORS = re.compile(r"[\s\-\u2010-\u2015\u2212]+")
|
|||||||
_INVISIBLE = re.compile("[\u00ad\u200b\ufeff]")
|
_INVISIBLE = re.compile("[\u00ad\u200b\ufeff]")
|
||||||
# Что нормализация убирает из текста: таких символов в паттерне быть не может
|
# Что нормализация убирает из текста: таких символов в паттерне быть не может
|
||||||
_UNMATCHABLE_IN_PATTERN = re.compile(r"[ёЁ\s\-\u2010-\u2015\u2212\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")
|
Value = TypeVar("Value")
|
||||||
|
|
||||||
@ -295,21 +323,221 @@ class DataTypeRules(_Model):
|
|||||||
return None
|
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)
|
@dataclass(frozen=True)
|
||||||
class Rules:
|
class Rules:
|
||||||
headers: OsvHeaderRules
|
headers: OsvHeaderRules
|
||||||
groups: OsvGroupRules
|
groups: OsvGroupRules
|
||||||
periods: PeriodRules
|
periods: PeriodRules
|
||||||
data_types: DataTypeRules
|
data_types: DataTypeRules
|
||||||
|
statement_headers: StatementHeaderRules
|
||||||
|
statement_periods: StatementPeriodRules
|
||||||
|
statement_checks: StatementCheckRules
|
||||||
|
|
||||||
|
|
||||||
def load_rules(path: Path = DEFAULT_RULES_DIR) -> Rules:
|
def load_rules(path: Path = DEFAULT_RULES_DIR) -> Rules:
|
||||||
"""Читает четыре словаря каталога и валидирует их; любая проблема — `RuntimeError` с файлом и ключом."""
|
"""Читает семь словарей каталога и валидирует их; любая проблема — `RuntimeError` с файлом и ключом."""
|
||||||
return Rules(
|
return Rules(
|
||||||
headers=_load(path, HEADERS_FILE, OsvHeaderRules),
|
headers=_load(path, HEADERS_FILE, OsvHeaderRules),
|
||||||
groups=_load(path, GROUPS_FILE, OsvGroupRules),
|
groups=_load(path, GROUPS_FILE, OsvGroupRules),
|
||||||
periods=_load(path, PERIODS_FILE, PeriodRules),
|
periods=_load(path, PERIODS_FILE, PeriodRules),
|
||||||
data_types=_load(path, DATA_TYPES_FILE, DataTypeRules),
|
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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
110
api/tests/unit/parser/test_statement_rules.py
Normal file
110
api/tests/unit/parser/test_statement_rules.py
Normal 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)
|
||||||
Loading…
x
Reference in New Issue
Block a user