feat: словари нормализации (§7, периоды, заголовки, единицы, коды, предупреждения) и загрузчик
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
parent
03a7ae1a59
commit
25b8bc4abf
72
arm_statements/issues.py
Normal file
72
arm_statements/issues.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""Предупреждения нормализации: перечень кодов и фабрика с уровнем из словаря."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from arm_statements.model import StatementWarning
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from arm_statements.loader import Rules
|
||||
|
||||
|
||||
class WarningCode(StrEnum):
|
||||
"""Коды предупреждений: по члену на код `rules/statement_warnings.yaml` (имя = значение)."""
|
||||
|
||||
JSON_FAILED = "JSON_FAILED"
|
||||
TABLE_PARSE_ERROR = "TABLE_PARSE_ERROR"
|
||||
TABLE_LOST = "TABLE_LOST"
|
||||
TABLE_SKIPPED = "TABLE_SKIPPED"
|
||||
CODE_COLUMN_NOT_FOUND = "CODE_COLUMN_NOT_FOUND"
|
||||
SEGMENT_WITHOUT_CODES = "SEGMENT_WITHOUT_CODES"
|
||||
SHEET_NOT_JOINED = "SHEET_NOT_JOINED"
|
||||
CONTINUATION_TYPE_CONFLICT = "CONTINUATION_TYPE_CONFLICT"
|
||||
SEGMENT_COLUMNS_MISMATCH = "SEGMENT_COLUMNS_MISMATCH"
|
||||
COLUMN_WITHOUT_DATE = "COLUMN_WITHOUT_DATE"
|
||||
COLUMN_DATE_UNCERTAIN = "COLUMN_DATE_UNCERTAIN"
|
||||
HEADER_ROW_NOT_FOUND = "HEADER_ROW_NOT_FOUND"
|
||||
TYPE_CONFLICT = "TYPE_CONFLICT"
|
||||
NO_ORGANIZATION_NO_INN = "NO_ORGANIZATION_NO_INN"
|
||||
PERIOD_UNKNOWN = "PERIOD_UNKNOWN"
|
||||
REPORT_DATE_UNCERTAIN = "REPORT_DATE_UNCERTAIN"
|
||||
XLSB_SCALE_SUSPECT = "XLSB_SCALE_SUSPECT"
|
||||
INN_NOT_FOUND = "INN_NOT_FOUND"
|
||||
INN_UNCERTAIN = "INN_UNCERTAIN"
|
||||
ORGANIZATION_NOT_FOUND = "ORGANIZATION_NOT_FOUND"
|
||||
ORGANIZATION_UNCERTAIN = "ORGANIZATION_UNCERTAIN"
|
||||
REPORT_DATE_UNPARSED = "REPORT_DATE_UNPARSED"
|
||||
UNITS_NOT_FOUND = "UNITS_NOT_FOUND"
|
||||
FILE_TYPE_MISMATCH = "FILE_TYPE_MISMATCH"
|
||||
XLS_NO_CODEPAGE = "XLS_NO_CODEPAGE"
|
||||
COLUMN_SKIPPED = "COLUMN_SKIPPED"
|
||||
FRACTIONAL_IN_THOUSANDS = "FRACTIONAL_IN_THOUSANDS"
|
||||
XLSB_NO_FORMATS = "XLSB_NO_FORMATS"
|
||||
NUMBER_FORMAT_UNPARSED = "NUMBER_FORMAT_UNPARSED"
|
||||
VALUE_OUT_OF_RANGE = "VALUE_OUT_OF_RANGE"
|
||||
SHEET_OTHER = "SHEET_OTHER"
|
||||
ROW_SPLIT = "ROW_SPLIT"
|
||||
|
||||
|
||||
def make_warning( # noqa: PLR0913 -- сигнатура задана публичным API пакета
|
||||
code: str,
|
||||
message: str,
|
||||
rules: "Rules",
|
||||
*,
|
||||
line_refs: Sequence[str] = (),
|
||||
column_index: int | None = None,
|
||||
field: str | None = None,
|
||||
locator: str | None = None,
|
||||
) -> StatementWarning:
|
||||
"""Предупреждение с уровнем из словаря; неизвестный код — `KeyError` (выдуманный код не создаётся)."""
|
||||
rule = rules.warnings.get(code)
|
||||
if rule is None:
|
||||
raise KeyError(f"код предупреждения {code} не задан в statement_warnings.yaml")
|
||||
return StatementWarning(
|
||||
code=str(code),
|
||||
severity=rule.severity,
|
||||
message=message,
|
||||
line_refs=tuple(line_refs),
|
||||
column_index=column_index,
|
||||
field=field,
|
||||
locator=locator,
|
||||
)
|
||||
509
arm_statements/loader.py
Normal file
509
arm_statements/loader.py
Normal file
@ -0,0 +1,509 @@
|
||||
"""Загрузчик словарей `rules/*.yaml` в типизированный `Rules` с проверкой при загрузке."""
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from enum import StrEnum
|
||||
from functools import cache
|
||||
from pathlib import Path
|
||||
from typing import Literal, TypeVar
|
||||
|
||||
import yaml
|
||||
|
||||
from arm_statements.issues import WarningCode
|
||||
from arm_statements.model import DocumentType, Edition, FormVariant, PeriodKind, Severity
|
||||
|
||||
LABEL_KEYS: tuple[str, ...] = (
|
||||
"organization",
|
||||
"inn",
|
||||
"okpo",
|
||||
"okopf",
|
||||
"okfs",
|
||||
"okopf_okfs",
|
||||
"okei",
|
||||
"units",
|
||||
"address",
|
||||
"date",
|
||||
)
|
||||
|
||||
_CHECKS = "statement_checks.yaml"
|
||||
_PERIODS = "statement_periods.yaml"
|
||||
_HEADERS = "statement_headers.yaml"
|
||||
_UNITS = "statement_units.yaml"
|
||||
_RANGES = "statement_code_ranges.yaml"
|
||||
_ORDERS = "statement_code_orders.yaml"
|
||||
_WARNINGS = "statement_warnings.yaml"
|
||||
_LOOKALIKES = "ocr_lookalikes.yaml"
|
||||
|
||||
_RELATION = re.compile(r"^(\d{4}) = (\d{4})((?: [+-] \d{4})*)$")
|
||||
_RELATION_TERM = re.compile(r"([+-]) (\d{4})")
|
||||
_PERIOD_GROUPS = frozenset({"d", "m", "mw", "y", "q", "n"})
|
||||
_ABSOLUTE_KINDS = frozenset({"range_end", "day_month_year", "quarter", "half", "months", "month_range", "year"})
|
||||
_MONTH_MAX = 12
|
||||
_PERCENT_MAX = 100
|
||||
|
||||
_E = TypeVar("_E", bound=StrEnum)
|
||||
|
||||
|
||||
class RulesError(ValueError):
|
||||
"""Словарь не прошёл проверку (в сообщении — файл и ключ)."""
|
||||
|
||||
|
||||
class CodeSection(StrEnum):
|
||||
BALANCE = "BALANCE"
|
||||
INCOME = "INCOME"
|
||||
CAPITAL = "CAPITAL"
|
||||
CASH_FLOW = "CASH_FLOW"
|
||||
NOTES = "NOTES"
|
||||
TARGET_USE = "TARGET_USE"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CodeRange:
|
||||
section: CodeSection
|
||||
low: int
|
||||
high: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CodeRanges:
|
||||
ranges: tuple[CodeRange, ...]
|
||||
min_share: Decimal
|
||||
min_codes: int
|
||||
min_codes_continuation: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RelationRule:
|
||||
form: DocumentType
|
||||
variant: FormVariant
|
||||
relation: str
|
||||
target: str
|
||||
terms: tuple[tuple[int, str], ...]
|
||||
strict: bool
|
||||
abs_terms: frozenset[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CheckRules:
|
||||
tolerance: Decimal
|
||||
rounding_tolerance_percent: Decimal
|
||||
subline_check: bool
|
||||
relations: tuple[RelationRule, ...]
|
||||
|
||||
def relations_for(self, form: DocumentType, variant: FormVariant) -> tuple[RelationRule, ...]:
|
||||
return tuple(r for r in self.relations if r.form == form and r.variant == variant)
|
||||
|
||||
def abs_codes(self, form: DocumentType, variant: FormVariant) -> frozenset[str]:
|
||||
found: set[str] = set()
|
||||
for rule in self.relations_for(form, variant):
|
||||
found |= rule.abs_terms
|
||||
return frozenset(found)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PeriodPattern:
|
||||
kind: str
|
||||
pattern: re.Pattern[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PeriodRules:
|
||||
relative: tuple[tuple[PeriodKind, re.Pattern[str]], ...]
|
||||
absolute: tuple[PeriodPattern, ...]
|
||||
months: tuple[tuple[str, int], ...]
|
||||
min_year: int
|
||||
max_year: int
|
||||
max_text_length: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class HeaderRules:
|
||||
form_titles: Mapping[DocumentType, tuple[re.Pattern[str], ...]]
|
||||
okud: Mapping[DocumentType, tuple[re.Pattern[str], ...]]
|
||||
okud_code: re.Pattern[str]
|
||||
continuation: tuple[re.Pattern[str], ...]
|
||||
code_caption: re.Pattern[str]
|
||||
name_caption: re.Pattern[str]
|
||||
explanations_caption: re.Pattern[str]
|
||||
column_exclusions: tuple[re.Pattern[str], ...]
|
||||
labels: Mapping[str, tuple[re.Pattern[str], ...]]
|
||||
auditor: tuple[re.Pattern[str], ...]
|
||||
legal_form: tuple[re.Pattern[str], ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnitRules:
|
||||
spellings: tuple[tuple[re.Pattern[str], str], ...]
|
||||
okei: Mapping[str, str]
|
||||
thousands: str
|
||||
fractional_share_percent: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CodeOrders:
|
||||
editions: tuple[Edition, ...]
|
||||
sequences: Mapping[tuple[Edition, DocumentType, FormVariant], tuple[str, ...]]
|
||||
|
||||
def sequence(self, edition: Edition, form: DocumentType, variant: FormVariant) -> tuple[str, ...]:
|
||||
return self.sequences.get((edition, form, variant), ())
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WarningRule:
|
||||
code: str
|
||||
severity: Severity
|
||||
scope: Literal["document", "file"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Rules:
|
||||
checks: CheckRules
|
||||
periods: PeriodRules
|
||||
headers: HeaderRules
|
||||
units: UnitRules
|
||||
code_ranges: CodeRanges
|
||||
code_orders: CodeOrders
|
||||
lookalikes: Mapping[str, str]
|
||||
warnings: Mapping[str, WarningRule]
|
||||
|
||||
|
||||
# --- проверяющие помощники: Any из YAML дальше них не уходит ---
|
||||
|
||||
|
||||
def _fail(file: str, key: str, message: str) -> RulesError:
|
||||
return RulesError(f"{file}: {key}: {message}")
|
||||
|
||||
|
||||
def _read(directory: Path, name: str) -> dict[str, object]:
|
||||
path = directory / name
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
raise _fail(name, "<файл>", f"не удалось прочитать: {exc}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise _fail(name, "<корень>", "ожидается словарь")
|
||||
return {str(k): v for k, v in data.items()}
|
||||
|
||||
|
||||
def _map(file: str, key: str, node: object) -> dict[str, object]:
|
||||
if not isinstance(node, dict):
|
||||
raise _fail(file, key, "ожидается словарь")
|
||||
return {str(k): v for k, v in node.items()}
|
||||
|
||||
|
||||
def _seq(file: str, key: str, node: object) -> list[object]:
|
||||
if not isinstance(node, list):
|
||||
raise _fail(file, key, "ожидается список")
|
||||
return list(node)
|
||||
|
||||
|
||||
def _get(file: str, data: Mapping[str, object], key: str) -> object:
|
||||
if key not in data:
|
||||
raise _fail(file, key, "ключ отсутствует")
|
||||
return data[key]
|
||||
|
||||
|
||||
def _str(file: str, key: str, node: object) -> str:
|
||||
if not isinstance(node, str):
|
||||
raise _fail(file, key, "ожидается строка")
|
||||
return node
|
||||
|
||||
|
||||
def _int(file: str, key: str, node: object) -> int:
|
||||
if isinstance(node, bool) or not isinstance(node, int):
|
||||
raise _fail(file, key, "ожидается целое число")
|
||||
return node
|
||||
|
||||
|
||||
def _bool(file: str, key: str, node: object) -> bool:
|
||||
if not isinstance(node, bool):
|
||||
raise _fail(file, key, "ожидается true/false")
|
||||
return node
|
||||
|
||||
|
||||
def _number(file: str, key: str, node: object) -> Decimal:
|
||||
if isinstance(node, bool) or not isinstance(node, int | float):
|
||||
raise _fail(file, key, "ожидается число")
|
||||
return Decimal(str(node))
|
||||
|
||||
|
||||
def _rx(file: str, key: str, node: object) -> re.Pattern[str]:
|
||||
text = _str(file, key, node)
|
||||
try:
|
||||
return re.compile(text, re.IGNORECASE)
|
||||
except re.error as exc:
|
||||
raise _fail(file, key, f"регулярка не компилируется: {exc}") from exc
|
||||
|
||||
|
||||
def _rx_list(file: str, key: str, node: object) -> tuple[re.Pattern[str], ...]:
|
||||
return tuple(_rx(file, f"{key}[{i}]", item) for i, item in enumerate(_seq(file, key, node)))
|
||||
|
||||
|
||||
def _enum(file: str, key: str, enum_type: type[_E], node: object) -> _E:
|
||||
try:
|
||||
return enum_type(_str(file, key, node))
|
||||
except ValueError as exc:
|
||||
raise _fail(file, key, f"недопустимое значение {node!r}") from exc
|
||||
|
||||
|
||||
def _doc_type_patterns(file: str, key: str, node: object) -> dict[DocumentType, tuple[re.Pattern[str], ...]]:
|
||||
return {
|
||||
_enum(file, f"{key}.{name}", DocumentType, name): _rx_list(file, f"{key}.{name}", value)
|
||||
for name, value in _map(file, key, node).items()
|
||||
}
|
||||
|
||||
|
||||
# --- разделы ---
|
||||
|
||||
|
||||
def _parse_relation(file: str, key: str, text: str) -> tuple[str, tuple[tuple[int, str], ...]]:
|
||||
match = _RELATION.match(text)
|
||||
if match is None:
|
||||
raise _fail(file, key, f"соотношение {text!r} не вида «<код> = <код> [+|-] <код> …»")
|
||||
terms = [(1, match.group(2))]
|
||||
terms += [(1 if sign == "+" else -1, code) for sign, code in _RELATION_TERM.findall(match.group(3))]
|
||||
return match.group(1), tuple(terms)
|
||||
|
||||
|
||||
def _load_checks(directory: Path) -> CheckRules:
|
||||
file = _CHECKS
|
||||
data = _read(directory, file)
|
||||
relations: list[RelationRule] = []
|
||||
for i, item in enumerate(_seq(file, "relations", _get(file, data, "relations"))):
|
||||
key = f"relations[{i}]"
|
||||
entry = _map(file, key, item)
|
||||
form = _enum(file, f"{key}.form", DocumentType, _get(file, entry, "form"))
|
||||
variant = _enum(file, f"{key}.variant", FormVariant, _get(file, entry, "variant"))
|
||||
text = _str(file, f"{key}.relation", _get(file, entry, "relation"))
|
||||
target, terms = _parse_relation(file, f"{key}.relation", text)
|
||||
raw_abs = _seq(file, f"{key}.abs_terms", entry.get("abs_terms", []))
|
||||
abs_terms = frozenset(_str(file, f"{key}.abs_terms", code) for code in raw_abs)
|
||||
if not abs_terms <= {code for _, code in terms}:
|
||||
raise _fail(file, f"{key}.abs_terms", "коды должны быть членами правой части соотношения")
|
||||
relations.append(
|
||||
RelationRule(
|
||||
form=form,
|
||||
variant=variant,
|
||||
relation=text,
|
||||
target=target,
|
||||
terms=terms,
|
||||
strict=_bool(file, f"{key}.strict", _get(file, entry, "strict")),
|
||||
abs_terms=abs_terms,
|
||||
)
|
||||
)
|
||||
return CheckRules(
|
||||
tolerance=_number(file, "tolerance", _get(file, data, "tolerance")),
|
||||
rounding_tolerance_percent=_number(
|
||||
file, "rounding_tolerance_percent", _get(file, data, "rounding_tolerance_percent")
|
||||
),
|
||||
subline_check=_bool(file, "subline_check", _get(file, data, "subline_check")),
|
||||
relations=tuple(relations),
|
||||
)
|
||||
|
||||
|
||||
def _load_periods(directory: Path) -> PeriodRules:
|
||||
file = _PERIODS
|
||||
data = _read(directory, file)
|
||||
relative: list[tuple[PeriodKind, re.Pattern[str]]] = []
|
||||
for i, item in enumerate(_seq(file, "relative", _get(file, data, "relative"))):
|
||||
key = f"relative[{i}]"
|
||||
entry = _map(file, key, item)
|
||||
kind = _enum(file, f"{key}.kind", PeriodKind, _get(file, entry, "kind"))
|
||||
relative.append((kind, _rx(file, f"{key}.pattern", _get(file, entry, "pattern"))))
|
||||
absolute: list[PeriodPattern] = []
|
||||
for i, item in enumerate(_seq(file, "absolute", _get(file, data, "absolute"))):
|
||||
key = f"absolute[{i}]"
|
||||
entry = _map(file, key, item)
|
||||
abs_kind = _str(file, f"{key}.kind", _get(file, entry, "kind"))
|
||||
if abs_kind not in _ABSOLUTE_KINDS:
|
||||
raise _fail(file, f"{key}.kind", f"недопустимый вид {abs_kind!r}")
|
||||
pattern = _rx(file, f"{key}.pattern", _get(file, entry, "pattern"))
|
||||
if not set(pattern.groupindex) <= _PERIOD_GROUPS:
|
||||
raise _fail(file, f"{key}.pattern", f"допустимые имена групп: {sorted(_PERIOD_GROUPS)}")
|
||||
absolute.append(PeriodPattern(kind, pattern))
|
||||
months: list[tuple[str, int]] = []
|
||||
for stem, number in _map(file, "months", _get(file, data, "months")).items():
|
||||
month = _int(file, f"months.{stem}", number)
|
||||
if not 1 <= month <= _MONTH_MAX:
|
||||
raise _fail(file, f"months.{stem}", "номер месяца вне 1..12")
|
||||
months.append((stem, month))
|
||||
years = _map(file, "years", _get(file, data, "years"))
|
||||
min_year = _int(file, "years.min", _get(file, years, "min"))
|
||||
max_year = _int(file, "years.max", _get(file, years, "max"))
|
||||
if min_year > max_year:
|
||||
raise _fail(file, "years", "min больше max")
|
||||
return PeriodRules(
|
||||
relative=tuple(relative),
|
||||
absolute=tuple(absolute),
|
||||
months=tuple(months),
|
||||
min_year=min_year,
|
||||
max_year=max_year,
|
||||
max_text_length=_int(file, "max_text_length", _get(file, data, "max_text_length")),
|
||||
)
|
||||
|
||||
|
||||
def _load_headers(directory: Path) -> HeaderRules:
|
||||
file = _HEADERS
|
||||
data = _read(directory, file)
|
||||
captions = _map(file, "captions", _get(file, data, "captions"))
|
||||
labels_raw = _map(file, "labels", _get(file, data, "labels"))
|
||||
if tuple(labels_raw) != LABEL_KEYS:
|
||||
raise _fail(file, "labels", f"ключи должны быть ровно {LABEL_KEYS}, получены {tuple(labels_raw)}")
|
||||
return HeaderRules(
|
||||
form_titles=_doc_type_patterns(file, "form_titles", _get(file, data, "form_titles")),
|
||||
okud=_doc_type_patterns(file, "okud", _get(file, data, "okud")),
|
||||
okud_code=_rx(file, "okud_code", _get(file, data, "okud_code")),
|
||||
continuation=_rx_list(file, "continuation", _get(file, data, "continuation")),
|
||||
code_caption=_rx(file, "captions.code", _get(file, captions, "code")),
|
||||
name_caption=_rx(file, "captions.name", _get(file, captions, "name")),
|
||||
explanations_caption=_rx(file, "captions.explanations", _get(file, captions, "explanations")),
|
||||
column_exclusions=_rx_list(file, "captions.exclusions", _get(file, captions, "exclusions")),
|
||||
labels={name: _rx_list(file, f"labels.{name}", value) for name, value in labels_raw.items()},
|
||||
auditor=_rx_list(file, "auditor", _get(file, data, "auditor")),
|
||||
legal_form=_rx_list(file, "legal_form", _get(file, data, "legal_form")),
|
||||
)
|
||||
|
||||
|
||||
def _load_units(directory: Path) -> UnitRules:
|
||||
file = _UNITS
|
||||
data = _read(directory, file)
|
||||
spellings: list[tuple[re.Pattern[str], str]] = []
|
||||
canonical_all: set[str] = set()
|
||||
for i, item in enumerate(_seq(file, "units", _get(file, data, "units"))):
|
||||
key = f"units[{i}]"
|
||||
entry = _map(file, key, item)
|
||||
canonical = _str(file, f"{key}.canonical", _get(file, entry, "canonical"))
|
||||
canonical_all.add(canonical)
|
||||
spellings.extend((rx, canonical) for rx in _rx_list(file, f"{key}.patterns", _get(file, entry, "patterns")))
|
||||
okei = {
|
||||
code: _str(file, f"okei.{code}", value) for code, value in _map(file, "okei", _get(file, data, "okei")).items()
|
||||
}
|
||||
for code, canonical in okei.items():
|
||||
if canonical not in canonical_all:
|
||||
raise _fail(file, f"okei.{code}", f"единица {canonical!r} не среди canonical")
|
||||
thousands = _str(file, "thousands", _get(file, data, "thousands"))
|
||||
if thousands not in canonical_all:
|
||||
raise _fail(file, "thousands", f"единица {thousands!r} не среди canonical")
|
||||
percent = _int(file, "fractional_share_percent", _get(file, data, "fractional_share_percent"))
|
||||
if not 1 <= percent <= _PERCENT_MAX:
|
||||
raise _fail(file, "fractional_share_percent", "ожидается 1..100")
|
||||
return UnitRules(tuple(spellings), okei, thousands, percent)
|
||||
|
||||
|
||||
def _load_ranges(directory: Path) -> CodeRanges:
|
||||
file = _RANGES
|
||||
data = _read(directory, file)
|
||||
ranges: list[CodeRange] = []
|
||||
for name, bounds in _map(file, "sections", _get(file, data, "sections")).items():
|
||||
section = _enum(file, f"sections.{name}", CodeSection, name)
|
||||
pair = _seq(file, f"sections.{name}", bounds)
|
||||
if len(pair) != len(("low", "high")):
|
||||
raise _fail(file, f"sections.{name}", "ожидается [low, high]")
|
||||
low, high = (_int(file, f"sections.{name}", v) for v in pair)
|
||||
if low > high:
|
||||
raise _fail(file, f"sections.{name}", "low больше high")
|
||||
ranges.append(CodeRange(section, low, high))
|
||||
min_codes = _int(file, "min_codes", _get(file, data, "min_codes"))
|
||||
continuation = _int(file, "min_codes_continuation", _get(file, data, "min_codes_continuation"))
|
||||
if not 1 <= continuation <= min_codes:
|
||||
raise _fail(file, "min_codes_continuation", "должно быть 1 <= min_codes_continuation <= min_codes")
|
||||
return CodeRanges(
|
||||
ranges=tuple(ranges),
|
||||
min_share=_number(file, "min_share", _get(file, data, "min_share")),
|
||||
min_codes=min_codes,
|
||||
min_codes_continuation=continuation,
|
||||
)
|
||||
|
||||
|
||||
def _load_orders(directory: Path) -> CodeOrders:
|
||||
file = _ORDERS
|
||||
data = _read(directory, file)
|
||||
if _get(file, data, "version") != 1:
|
||||
raise _fail(file, "version", "поддерживается только версия 1")
|
||||
repeats = _map(file, "repeats", _get(file, data, "repeats"))
|
||||
editions_raw = _map(file, "editions", _get(file, data, "editions"))
|
||||
editions: list[Edition] = []
|
||||
sequences: dict[tuple[Edition, DocumentType, FormVariant], tuple[str, ...]] = {}
|
||||
for ed_name, forms_node in editions_raw.items():
|
||||
edition = _enum(file, f"editions.{ed_name}", Edition, ed_name)
|
||||
editions.append(edition)
|
||||
forms = _map(file, f"editions.{ed_name}", forms_node)
|
||||
edition_repeats = _map(file, f"repeats.{ed_name}", repeats.get(ed_name, {}))
|
||||
for form_name, variants_node in forms.items():
|
||||
form = _enum(file, f"editions.{ed_name}.{form_name}", DocumentType, form_name)
|
||||
repeat_codes = [
|
||||
_str(file, f"repeats.{ed_name}.{form_name}", c)
|
||||
for c in _seq(file, f"repeats.{ed_name}.{form_name}", edition_repeats.get(form_name, []))
|
||||
]
|
||||
variants = _map(file, f"editions.{ed_name}.{form_name}", variants_node)
|
||||
for var_name, codes_node in variants.items():
|
||||
key = f"editions.{ed_name}.{form_name}.{var_name}"
|
||||
variant = _enum(file, key, FormVariant, var_name)
|
||||
codes = tuple(_str(file, key, c) for c in _seq(file, key, codes_node))
|
||||
for code in codes:
|
||||
if not re.fullmatch(r"\d{4}", code):
|
||||
raise _fail(file, key, f"код {code!r} не из четырёх цифр")
|
||||
_check_repeat_counts(file, key, variant, codes, repeat_codes)
|
||||
sequences[(edition, form, variant)] = codes
|
||||
return CodeOrders(tuple(editions), sequences)
|
||||
|
||||
|
||||
def _check_repeat_counts(file: str, key: str, variant: FormVariant, codes: tuple[str, ...], repeats: list[str]) -> None:
|
||||
expected = {code: 1 + repeats.count(code) for code in set(codes) | set(repeats)}
|
||||
for code, limit in sorted(expected.items()):
|
||||
actual = codes.count(code)
|
||||
if variant is FormVariant.FULL and actual != limit:
|
||||
raise _fail(file, key, f"код {code} встречается {actual} раз, ожидается {limit} (1 + repeats)")
|
||||
if variant is FormVariant.SIMPLIFIED and actual > limit:
|
||||
raise _fail(file, key, f"код {code} встречается {actual} раз, допустимо не более {limit}")
|
||||
|
||||
|
||||
def _load_warnings(directory: Path) -> dict[str, WarningRule]:
|
||||
file = _WARNINGS
|
||||
data = _read(directory, file)
|
||||
warnings: dict[str, WarningRule] = {}
|
||||
for code, node in _map(file, "warnings", _get(file, data, "warnings")).items():
|
||||
entry = _map(file, f"warnings.{code}", node)
|
||||
severity = _enum(file, f"warnings.{code}.severity", Severity, _get(file, entry, "severity"))
|
||||
scope = _str(file, f"warnings.{code}.scope", _get(file, entry, "scope"))
|
||||
if scope == "document":
|
||||
warnings[code] = WarningRule(code, severity, "document")
|
||||
elif scope == "file":
|
||||
warnings[code] = WarningRule(code, severity, "file")
|
||||
else:
|
||||
raise _fail(file, f"warnings.{code}.scope", f"недопустимая область {scope!r}")
|
||||
if set(warnings) != {c.value for c in WarningCode}:
|
||||
diff = sorted(set(warnings) ^ {c.value for c in WarningCode})
|
||||
raise _fail(file, "warnings", f"коды словаря и WarningCode расходятся: {diff}")
|
||||
return warnings
|
||||
|
||||
|
||||
def _load_lookalikes(directory: Path) -> dict[str, str]:
|
||||
file = _LOOKALIKES
|
||||
data = _read(directory, file)
|
||||
return {
|
||||
char: _str(file, f"lookalikes.{char}", value)
|
||||
for char, value in _map(file, "lookalikes", _get(file, data, "lookalikes")).items()
|
||||
}
|
||||
|
||||
|
||||
def load_rules(directory: Path | None = None) -> Rules:
|
||||
"""Прочитать и проверить словари каталога (по умолчанию — `rules/` пакета)."""
|
||||
base = directory if directory is not None else Path(__file__).parent / "rules"
|
||||
return Rules(
|
||||
checks=_load_checks(base),
|
||||
periods=_load_periods(base),
|
||||
headers=_load_headers(base),
|
||||
units=_load_units(base),
|
||||
code_ranges=_load_ranges(base),
|
||||
code_orders=_load_orders(base),
|
||||
lookalikes=_load_lookalikes(base),
|
||||
warnings=_load_warnings(base),
|
||||
)
|
||||
|
||||
|
||||
@cache
|
||||
def default_rules() -> Rules:
|
||||
return load_rules()
|
||||
16
arm_statements/rules/ocr_lookalikes.yaml
Normal file
16
arm_statements/rules/ocr_lookalikes.yaml
Normal file
@ -0,0 +1,16 @@
|
||||
# Буквы и знаки, которые OCR ставит вместо цифр (§11.1 п. 1). Замена — только вплотную к цифре, в несколько проходов.
|
||||
lookalikes:
|
||||
"О": "0"
|
||||
"о": "0"
|
||||
"O": "0"
|
||||
"o": "0"
|
||||
"З": "3"
|
||||
"з": "3"
|
||||
"б": "6"
|
||||
"S": "5"
|
||||
"s": "5"
|
||||
"I": "1"
|
||||
"l": "1"
|
||||
"|": "1"
|
||||
"і": "1"
|
||||
"Ч": "4"
|
||||
29
arm_statements/rules/statement_checks.yaml
Normal file
29
arm_statements/rules/statement_checks.yaml
Normal file
@ -0,0 +1,29 @@
|
||||
# Соотношения §7 (05-domain-rules.md), ББ и ОФР. Данные, не код: новое соотношение — строка здесь.
|
||||
# variant: full — полный набор; simplified — упрощённый набор (спека «Шаг 7», §7 «Упрощённый набор»).
|
||||
# relation: «<код> = <код> [+|-] <код> …», только четырёхзначные коды; strict: нарушение переводит документ в needs_check
|
||||
# (false — только мягкое предупреждение, ASSUMPTION(OQ-12)); abs_terms: коды, которые 1С выгружает со знаком минус, а 66н —
|
||||
# в скобках: правая часть считается и как записано, и с этими членами по модулю.
|
||||
tolerance: 1 # допуск в единицах формы (тыс. руб. округляются построчно)
|
||||
# OQ-138: единицы шапки верны, расхождение до 0,001 % итоговой строки — округление (мягкое нарушение, статус не портит).
|
||||
rounding_tolerance_percent: 0.001
|
||||
subline_check: true # «сумма пятизначных подстрок = родитель» — мягкая проверка, генерируется по коду
|
||||
relations:
|
||||
- {form: BALANCE, variant: full, relation: "1100 = 1105 + 1110 + 1120 + 1130 + 1140 + 1150 + 1160 + 1170 + 1180 + 1190", strict: true}
|
||||
- {form: BALANCE, variant: full, relation: "1200 = 1210 + 1215 + 1220 + 1230 + 1240 + 1250 + 1260", strict: true}
|
||||
- {form: BALANCE, variant: full, relation: "1300 = 1310 + 1320 + 1340 + 1350 + 1360 + 1370", strict: true}
|
||||
- {form: BALANCE, variant: full, relation: "1400 = 1410 + 1420 + 1430 + 1450", strict: true}
|
||||
- {form: BALANCE, variant: full, relation: "1500 = 1510 + 1520 + 1530 + 1540 + 1550", strict: true}
|
||||
- {form: BALANCE, variant: full, relation: "1600 = 1100 + 1200", strict: true}
|
||||
- {form: BALANCE, variant: full, relation: "1700 = 1300 + 1400 + 1500", strict: true}
|
||||
- {form: BALANCE, variant: full, relation: "1600 = 1700", strict: true}
|
||||
- {form: INCOME, variant: full, relation: "2100 = 2110 - 2120", strict: true, abs_terms: ["2120"]}
|
||||
- {form: INCOME, variant: full, relation: "2200 = 2100 - 2210 - 2220", strict: true, abs_terms: ["2210", "2220"]}
|
||||
- {form: INCOME, variant: full, relation: "2300 = 2200 + 2310 + 2320 - 2330 + 2340 - 2350", strict: true, abs_terms: ["2330", "2350"]}
|
||||
- {form: INCOME, variant: full, relation: "2400 = 2300 - 2410 + 2420", strict: false, abs_terms: ["2410"]}
|
||||
- {form: INCOME, variant: full, relation: "2400 = 2300 + 2410 + 2460", strict: false}
|
||||
- {form: INCOME, variant: full, relation: "2410 = 2411 + 2412", strict: false}
|
||||
- {form: INCOME, variant: full, relation: "2500 = 2400 + 2510 + 2520 + 2530", strict: false}
|
||||
- {form: BALANCE, variant: simplified, relation: "1600 = 1150 + 1170 + 1210 + 1230 + 1250", strict: true}
|
||||
- {form: BALANCE, variant: simplified, relation: "1700 = 1300 + 1350 + 1360 + 1410 + 1450 + 1510 + 1520 + 1550", strict: true}
|
||||
- {form: BALANCE, variant: simplified, relation: "1600 = 1700", strict: true}
|
||||
- {form: INCOME, variant: simplified, relation: "2400 = 2110 - 2120 - 2330 + 2340 - 2350 - 2410", strict: false, abs_terms: ["2120", "2330", "2350", "2410"]}
|
||||
194
arm_statements/rules/statement_code_orders.yaml
Normal file
194
arm_statements/rules/statement_code_orders.yaml
Normal file
@ -0,0 +1,194 @@
|
||||
# Упорядоченные перечни кодов строк форм ББ/ОФР по редакциям и вариантам — данные для структурного подтверждения
|
||||
# неуверенного кода (05-domain-rules.md §11.9 «Код строки»; change unified-statement-normalization, K-5, Р-20).
|
||||
#
|
||||
# Что это: порядок кодов бланка (итоги после слагаемых: 1110 … 1190, 1100), без наименований (Р-3: «код → наименование»
|
||||
# знает только ядро, arm-core/api/rules/statement_lines.yaml). Только четырёхзначные коды: пятизначная подстрока
|
||||
# подтверждается через родителя (§11.9). Перечень — объединение кодов шаблонов банка (словарь имён ядра,
|
||||
# fixtures/statements-xlsx/templates/) и кодов печатных форм 1С и сканов, найденных в fixtures (источник — комментарий
|
||||
# у кода). Код без образца в fixtures сюда не добавляется: кандидаты — OQ-168.
|
||||
#
|
||||
# Где живёт: этот файл — источник; в пакете arm_statements лежит его байтовая копия (rules/statement_code_orders.yaml),
|
||||
# идентичность проверяет sync-statements --check (как копии схем контракта в сервисах). Ядро берёт порядок и состав
|
||||
# редакций из копии пакета; sync-statements --check сверяет, что все коды словаря имён ядра есть в перечне своей
|
||||
# редакции. Правка перечня — PR сюда, затем в arm-statements, затем sync-statements в потребителях («сначала ядро»).
|
||||
#
|
||||
# Как читается (§11.9): четырёхзначные коды строк ok документа — последовательность; для каждой редакции берётся
|
||||
# наибольшая подпоследовательность, согласованная с порядком перечня варианта формы документа (form_variant); выбирается
|
||||
# редакция с более длинной (при равенстве — по эффективной дате: fsbu4 после 2024-12-31). Повтор кода допустим только
|
||||
# там, где он есть в перечне (repeats): тогда структура подтверждает обе копии; повтор вне перечня с неуверенной копией —
|
||||
# needs_review у неуверенной копии (Р-16, Р-20).
|
||||
#
|
||||
# Проверка формы файла — scripts/validate_contracts.py (make validate-contracts).
|
||||
version: 1
|
||||
|
||||
# Законные повторы кода в одном перечне (Р-20). Ключ — редакция и форма; значение — коды, которые встречаются в
|
||||
# перечне больше одного раза: в варианте full код стоит ровно 1 + (число его вхождений в этот список) раз, в simplified —
|
||||
# не чаще (сейчас повторов в simplified нет; validate_contracts.py проверяет оба правила).
|
||||
repeats:
|
||||
66n:
|
||||
# раздел III для некоммерческих организаций («Целевое финансирование») печатается после раздела III коммерческих
|
||||
# (fixtures/ocr/aep_3.json, 31.12.2024: 1310 «Паевой фонд», 1320 «Целевой капитал», 1350 «Целевые средства»,
|
||||
# 1360 «Фонд недвижимого и особо ценного движимого имущества», 1370 «Резервный и иные целевые фонды»)
|
||||
BALANCE: ["1310", "1320", "1350", "1360", "1370", "1300"]
|
||||
INCOME: []
|
||||
fsbu4:
|
||||
# печатная форма 1С по ФСБУ 4/2023 (fixtures/ocr/aep_6.json, «БО 1 кв 2025.json», «Бухгалтерская отчетность
|
||||
# 1 кв.25.json»): 1220 «НДС, начисленный по отгрузке», 1520 «Обязательство по аренде» (AURORA-1431)
|
||||
BALANCE: ["1220", "1520"]
|
||||
# 2410 «Налог при упрощенной системе налогообложения» после 2412 (fixtures/ocr/aep_6.json, OQ-152)
|
||||
INCOME: ["2410"]
|
||||
|
||||
editions:
|
||||
66n:
|
||||
BALANCE:
|
||||
full:
|
||||
- "1110"
|
||||
- "1120"
|
||||
- "1130"
|
||||
- "1140"
|
||||
- "1150"
|
||||
- "1160"
|
||||
- "1170"
|
||||
- "1180"
|
||||
- "1190"
|
||||
- "1100"
|
||||
- "1210"
|
||||
- "1220"
|
||||
- "1230"
|
||||
- "1231" # код клиента: unagrande-group/2024-FY/ББ. Лист 1 .xls
|
||||
- "1240"
|
||||
- "1250"
|
||||
- "1260"
|
||||
- "1200"
|
||||
- "1600"
|
||||
- "1310"
|
||||
- "1320"
|
||||
- "1340"
|
||||
- "1350"
|
||||
- "1360"
|
||||
- "1370"
|
||||
- "1300"
|
||||
- "1310" # раздел III НКО (aep_3), см. repeats
|
||||
- "1320"
|
||||
- "1350"
|
||||
- "1360"
|
||||
- "1370"
|
||||
- "1300"
|
||||
- "1410"
|
||||
- "1420"
|
||||
- "1430"
|
||||
- "1450"
|
||||
- "1400"
|
||||
- "1510"
|
||||
- "1520"
|
||||
- "1530"
|
||||
- "1540"
|
||||
- "1550"
|
||||
- "1500"
|
||||
- "1700"
|
||||
# упрощённая форма, приказ 66н прил. 5 — по бланку, образца в fixtures нет (OQ-166)
|
||||
simplified: ["1150", "1170", "1210", "1250", "1230", "1600", "1300", "1350", "1360", "1410", "1450", "1510",
|
||||
"1520", "1550", "1700"]
|
||||
INCOME:
|
||||
full:
|
||||
- "2110"
|
||||
- "2120"
|
||||
- "2100"
|
||||
- "2210"
|
||||
- "2220"
|
||||
- "2200"
|
||||
- "2310"
|
||||
- "2320"
|
||||
- "2330"
|
||||
- "2340"
|
||||
- "2350"
|
||||
- "2300"
|
||||
- "2410"
|
||||
- "2411"
|
||||
- "2412"
|
||||
- "2460"
|
||||
- "2400"
|
||||
- "2510"
|
||||
- "2520"
|
||||
- "2530" # печатная форма 1С: unagrande-co/2024-FY/ОФР. Лист 2 .xls, mezheninovskaya/2025-Q3
|
||||
- "2500"
|
||||
- "2900"
|
||||
- "2910"
|
||||
- "2930" # печатная форма 1С: fixtures/ocr/aep_3.json (после 2900)
|
||||
# упрощённая форма, приказ 66н прил. 5 — по бланку, образца нет (OQ-166)
|
||||
simplified: ["2110", "2120", "2330", "2340", "2350", "2410", "2400"]
|
||||
fsbu4:
|
||||
BALANCE:
|
||||
full:
|
||||
- "1105" # печатная форма 1С: npz/2025-Q3, fixtures/ocr/aep_6.json, «БО 1 кв 2025.json»
|
||||
- "1110"
|
||||
- "1130" # печатная форма 1С: npz/2025-Q3, «БО 1 кв 2025.json», «Бухгалтерская отчетность 1 кв.25.json»
|
||||
- "1140" # то же и aep_6.json
|
||||
- "1150"
|
||||
- "1160"
|
||||
- "1170"
|
||||
- "1180"
|
||||
- "1190"
|
||||
- "1100"
|
||||
- "1210"
|
||||
- "1215"
|
||||
- "1220"
|
||||
- "1220" # «НДС, начисленный по отгрузке», см. repeats
|
||||
- "1230"
|
||||
- "1240"
|
||||
- "1250"
|
||||
- "1260"
|
||||
- "1200"
|
||||
- "1600"
|
||||
- "1310"
|
||||
- "1320"
|
||||
- "1340"
|
||||
- "1350"
|
||||
- "1360"
|
||||
- "1370"
|
||||
- "1300"
|
||||
- "1410"
|
||||
- "1420"
|
||||
- "1430"
|
||||
- "1450"
|
||||
- "1400"
|
||||
- "1510"
|
||||
- "1520"
|
||||
- "1520" # «Обязательство по аренде», см. repeats
|
||||
- "1530" # печатная форма 1С: npz/2025-Q3, aep_6.json, оба json 1 кв. 2025
|
||||
- "1540"
|
||||
- "1550"
|
||||
- "1500"
|
||||
- "1700"
|
||||
# упрощённая форма по ФСБУ 4/2023 — состав принят как у 66н до образца (OQ-166, OQ-168)
|
||||
simplified: ["1150", "1170", "1210", "1250", "1230", "1600", "1300", "1350", "1360", "1410", "1450", "1510",
|
||||
"1520", "1550", "1700"]
|
||||
INCOME:
|
||||
full:
|
||||
- "2110"
|
||||
- "2120"
|
||||
- "2100"
|
||||
- "2210"
|
||||
- "2220"
|
||||
- "2200"
|
||||
- "2310"
|
||||
- "2320"
|
||||
- "2330"
|
||||
- "2340"
|
||||
- "2350"
|
||||
- "2300"
|
||||
- "2410"
|
||||
- "2411"
|
||||
- "2412"
|
||||
- "2410" # «Налог при упрощенной системе налогообложения», см. repeats
|
||||
- "2420"
|
||||
- "2460" # печатная форма 1С: npz/2025-Q3, aep_6.json, оба json 1 кв. 2025
|
||||
- "2400"
|
||||
- "2510"
|
||||
- "2520"
|
||||
- "2530"
|
||||
- "2500"
|
||||
- "2900"
|
||||
- "2910"
|
||||
# упрощённая форма по ФСБУ 4/2023 — как у 66н до образца (OQ-166, OQ-168)
|
||||
simplified: ["2110", "2120", "2330", "2340", "2350", "2410", "2400"]
|
||||
13
arm_statements/rules/statement_code_ranges.yaml
Normal file
13
arm_statements/rules/statement_code_ranges.yaml
Normal file
@ -0,0 +1,13 @@
|
||||
# Коды строк форм: диапазоны четырёхзначных кодов (пятизначные — по первым четырём цифрам), доля кодов в столбце,
|
||||
# минимум кодов, чтобы столбец считался столбцом кодов.
|
||||
sections:
|
||||
BALANCE: [1100, 1799]
|
||||
INCOME: [2100, 2999]
|
||||
CAPITAL: [3100, 3699]
|
||||
CASH_FLOW: [4100, 4499]
|
||||
NOTES: [5000, 5999]
|
||||
TARGET_USE: [6100, 6499]
|
||||
min_share: 0.6
|
||||
min_codes: 3
|
||||
# слабое продолжение (отрезок формы без шапки, лист-продолжение без маркера): хватает одного кода
|
||||
min_codes_continuation: 1
|
||||
78
arm_statements/rules/statement_headers.yaml
Normal file
78
arm_statements/rules/statement_headers.yaml
Normal file
@ -0,0 +1,78 @@
|
||||
# Словарь форм ББ/ОФР (05-domain-rules.md §12): заголовки форм и ОКУД, маркер продолжения, подписи колонок, метки шапки.
|
||||
# Шаблоны ищутся (re.search) без учёта регистра. Текст перед поиском: normalize_label (пунктуация сохраняется) для
|
||||
# form_titles, okud, okud_code, continuation, labels, auditor, legal_form; normalize_caption (пунктуация -> пробел)
|
||||
# для captions. Новый синоним = строка в 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 — расшифровки
|
||||
okud:
|
||||
BALANCE:
|
||||
- '\b0710001\b'
|
||||
INCOME:
|
||||
- '\b0710002\b'
|
||||
DISCLOSURE:
|
||||
- '\b07100(0(3|4|5|6|7|8|9)|(1|2|3|4|5|6|7|8|9)\d)\b'
|
||||
|
||||
# Сам код ОКУД формы внутри ячейки («Форма по ОКУД 0710001» -> «0710001»)
|
||||
okud_code: '0710\d{3}'
|
||||
|
||||
# Маркер страницы-продолжения в 1С-выгрузке: «Форма 0710001 с.2»
|
||||
continuation:
|
||||
- '\bформа\s+0710\d{3}\s+с\.?\s*\d+'
|
||||
|
||||
# Подписи колонок заголовка таблицы (ячейка целиком; исключения — подстроки)
|
||||
captions:
|
||||
code: '^код(ы|\s+строки|\s+показателя)?$'
|
||||
name: '^наименование'
|
||||
explanations: '^пояснени'
|
||||
exclusions: ['изменени', 'отклонени', '%', 'темп', 'доля', 'примечани']
|
||||
|
||||
# Метки шапки документа (значение — правее в той же строке либо в следующей строке)
|
||||
labels:
|
||||
organization:
|
||||
- '^организация\b'
|
||||
inn:
|
||||
- '^инн\b'
|
||||
- 'идентификационный\s+номер\s+налогоплательщика'
|
||||
okpo:
|
||||
- 'по\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'
|
||||
|
||||
# Строки блока аудитора: ИНН ниже этой метки — ИНН аудитора, не организации
|
||||
auditor:
|
||||
- 'аудитор'
|
||||
|
||||
# Признак, что строка содержит название организации (для имени над меткой «Организация», НПЗ)
|
||||
legal_form:
|
||||
- '\b(ооо|ао|пао|зао|оао|нао|ип|гуп|муп|фгуп)\b'
|
||||
- '"'
|
||||
- '«'
|
||||
44
arm_statements/rules/statement_periods.yaml
Normal file
44
arm_statements/rules/statement_periods.yaml
Normal file
@ -0,0 +1,44 @@
|
||||
# Периоды форм ББ/ОФР: подпись колонки или подзаголовок формы -> дата (конец периода). §11.3, §12 п. 4.
|
||||
# Регулярки ищутся без учёта регистра. Относительные подписи применяются к подписи после normalize_caption.
|
||||
max_text_length: 200 # длиннее — не дата (защита от backtracking)
|
||||
years: {min: 1990, max: 2100} # одинокий год вне диапазона датой не считается
|
||||
|
||||
# Основа названия месяца -> номер (сопоставляется по началу слова)
|
||||
months:
|
||||
январ: 1
|
||||
феврал: 2
|
||||
март: 3
|
||||
апрел: 4
|
||||
ма: 5
|
||||
июн: 6
|
||||
июл: 7
|
||||
август: 8
|
||||
сентябр: 9
|
||||
октябр: 10
|
||||
ноябр: 11
|
||||
декабр: 12
|
||||
|
||||
# Относительные подписи колонок (по порядку проверки)
|
||||
relative:
|
||||
- {kind: "relative:prev_prev_year_end", pattern: 'предшествующ\w*\s+предыдущ'}
|
||||
- {kind: "relative:prev_year_end", pattern: 'декабр\w*\s+предыдущ'}
|
||||
- {kind: "relative:prev_period", pattern: 'аналогичн\w*\s+период\w*\s+предыдущ'}
|
||||
- {kind: "relative:current", pattern: 'отч[её]тн\w*\s+(дат|период)'}
|
||||
|
||||
# Абсолютные формы по приоритету (§11.3 п. 3). Именованные группы: d (день), m (месяц числом), mw (месяц словом),
|
||||
# y (год, 2 или 4 цифры), q (номер квартала/полугодия словом, римской или арабской цифрой), n (число месяцев).
|
||||
absolute:
|
||||
- kind: range_end # «с 01.01.2025 по 30.09.2025»: конец диапазона
|
||||
pattern: 'по\s*(?P<d>\d{1,2})[.\-/ ](?P<m>\d{1,2})[.\-/ ](?P<y>\d{4}|\d{2})'
|
||||
- kind: day_month_year # «31 декабря 2025 г.», «на 30 сентября 2025 года»
|
||||
pattern: '(?P<d>\d{1,2})\s+(?P<mw>[а-яё]{3,8})\s+(?P<y>\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?'
|
||||
- kind: quarter # «I квартал 2026», «1-й кв. 2026», «первый квартал»
|
||||
pattern: '(?P<q>I{1,3}V?|IV|\d|перв\w*|втор\w*|трет\w*|четв\w*)\s*-?\s*(?:й|ый|ого|го)?\s*кв\w*\.?\s*(?P<y>\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?'
|
||||
- kind: half # «1 полугодие 2026», «первое полугодие», «1 п/г 2026»
|
||||
pattern: '(?P<q>I{1,3}V?|IV|\d|перв\w*|втор\w*|трет\w*|четв\w*)\s*-?\s*(?:е|ое|го)?\s*(?:полугоди\w*|п/г)\.?\s*(?P<y>\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?'
|
||||
- kind: months # «9 месяцев 2025», «9 мес. 2025»
|
||||
pattern: '(?P<n>\d{1,2})\s*мес\w*\.?\s*(?P<y>\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?'
|
||||
- kind: month_range # «январь - сентябрь 2025»: последний месяц
|
||||
pattern: '[а-яё]{3,8}\s*[-–—]\s*(?P<mw>[а-яё]{3,8})\s*(?P<y>\d{4}|\d{2})\s*(?:г\.?|r\.?|год\w*)?'
|
||||
- kind: year # «за 2025 г.», «2025 год», «2025г»
|
||||
pattern: '(?:за\s*)?(?:отч[её]тный\s*)?(?P<y>\d{4})\s*(?:г\b|г\.|r\b|r\.|год)'
|
||||
16
arm_statements/rules/statement_units.yaml
Normal file
16
arm_statements/rules/statement_units.yaml
Normal file
@ -0,0 +1,16 @@
|
||||
# Единицы измерения форм ББ/ОФР. Подпись проверяется по порядку: первая совпавшая запись определяет единицу.
|
||||
units:
|
||||
- canonical: "тыс. руб."
|
||||
patterns: ['тыс\w*\.?\s*руб', 'тысяч\w*\s+рубл']
|
||||
- canonical: "млн руб."
|
||||
patterns: ['млн\w*\.?\s*руб', 'миллион\w*\s+рубл']
|
||||
- canonical: "руб."
|
||||
patterns: ['руб']
|
||||
# Код ОКЕИ -> каноническая единица
|
||||
okei:
|
||||
"384": "тыс. руб."
|
||||
"383": "руб."
|
||||
"385": "млн руб."
|
||||
thousands: "тыс. руб." # единица, для которой проверяется доля дробных значений
|
||||
# Порог для предупреждения о дробных значениях при единицах в тыс. руб. (процент; 1..100)
|
||||
fractional_share_percent: 10
|
||||
36
arm_statements/rules/statement_warnings.yaml
Normal file
36
arm_statements/rules/statement_warnings.yaml
Normal file
@ -0,0 +1,36 @@
|
||||
# Предупреждения нормализации: код -> уровень и область (спека «Шаг 8 — предупреждения»).
|
||||
# scope: document — предупреждение документа; file — предупреждение файла (metrics.warnings).
|
||||
# Множество кодов равно перечислению WarningCode (arm_statements/issues.py); код добавляется в обоих местах.
|
||||
warnings:
|
||||
JSON_FAILED: {severity: error, scope: file}
|
||||
TABLE_PARSE_ERROR: {severity: error, scope: file}
|
||||
TABLE_LOST: {severity: error, scope: file}
|
||||
TABLE_SKIPPED: {severity: info, scope: file}
|
||||
CODE_COLUMN_NOT_FOUND: {severity: error, scope: document}
|
||||
SEGMENT_WITHOUT_CODES: {severity: error, scope: document}
|
||||
SHEET_NOT_JOINED: {severity: error, scope: document}
|
||||
CONTINUATION_TYPE_CONFLICT: {severity: error, scope: document}
|
||||
SEGMENT_COLUMNS_MISMATCH: {severity: error, scope: document}
|
||||
COLUMN_WITHOUT_DATE: {severity: error, scope: document}
|
||||
COLUMN_DATE_UNCERTAIN: {severity: error, scope: document}
|
||||
HEADER_ROW_NOT_FOUND: {severity: error, scope: document}
|
||||
TYPE_CONFLICT: {severity: error, scope: document}
|
||||
NO_ORGANIZATION_NO_INN: {severity: error, scope: document}
|
||||
PERIOD_UNKNOWN: {severity: error, scope: document}
|
||||
REPORT_DATE_UNCERTAIN: {severity: error, scope: document}
|
||||
XLSB_SCALE_SUSPECT: {severity: error, scope: document}
|
||||
INN_NOT_FOUND: {severity: info, scope: document}
|
||||
INN_UNCERTAIN: {severity: info, scope: document}
|
||||
ORGANIZATION_NOT_FOUND: {severity: info, scope: document}
|
||||
ORGANIZATION_UNCERTAIN: {severity: info, scope: document}
|
||||
REPORT_DATE_UNPARSED: {severity: info, scope: document}
|
||||
UNITS_NOT_FOUND: {severity: info, scope: document}
|
||||
FILE_TYPE_MISMATCH: {severity: info, scope: file}
|
||||
XLS_NO_CODEPAGE: {severity: info, scope: file}
|
||||
COLUMN_SKIPPED: {severity: info, scope: document}
|
||||
FRACTIONAL_IN_THOUSANDS: {severity: info, scope: document}
|
||||
XLSB_NO_FORMATS: {severity: info, scope: document}
|
||||
NUMBER_FORMAT_UNPARSED: {severity: info, scope: document}
|
||||
VALUE_OUT_OF_RANGE: {severity: info, scope: document}
|
||||
SHEET_OTHER: {severity: info, scope: file}
|
||||
ROW_SPLIT: {severity: info, scope: document}
|
||||
@ -1 +1,10 @@
|
||||
"""Общие фикстуры тестов пакета (фикстура `rules` появится в задаче 3)."""
|
||||
"""Общие фикстуры тестов пакета."""
|
||||
|
||||
import pytest
|
||||
|
||||
from arm_statements.loader import Rules, default_rules
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def rules() -> Rules:
|
||||
return default_rules()
|
||||
|
||||
141
arm_statements/tests/test_loader.py
Normal file
141
arm_statements/tests/test_loader.py
Normal file
@ -0,0 +1,141 @@
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from arm_statements.issues import WarningCode, make_warning
|
||||
from arm_statements.loader import LABEL_KEYS, Rules, RulesError, load_rules
|
||||
from arm_statements.model import DocumentType, Edition, FormVariant, Severity
|
||||
|
||||
RULES_DIR = Path(__file__).parent.parent / "rules"
|
||||
|
||||
SPEC_WARNINGS = {
|
||||
"JSON_FAILED": ("error", "file"),
|
||||
"TABLE_PARSE_ERROR": ("error", "file"),
|
||||
"TABLE_LOST": ("error", "file"),
|
||||
"TABLE_SKIPPED": ("info", "file"),
|
||||
"CODE_COLUMN_NOT_FOUND": ("error", "document"),
|
||||
"SEGMENT_WITHOUT_CODES": ("error", "document"),
|
||||
"SHEET_NOT_JOINED": ("error", "document"),
|
||||
"CONTINUATION_TYPE_CONFLICT": ("error", "document"),
|
||||
"SEGMENT_COLUMNS_MISMATCH": ("error", "document"),
|
||||
"COLUMN_WITHOUT_DATE": ("error", "document"),
|
||||
"COLUMN_DATE_UNCERTAIN": ("error", "document"),
|
||||
"HEADER_ROW_NOT_FOUND": ("error", "document"),
|
||||
"TYPE_CONFLICT": ("error", "document"),
|
||||
"NO_ORGANIZATION_NO_INN": ("error", "document"),
|
||||
"PERIOD_UNKNOWN": ("error", "document"),
|
||||
"REPORT_DATE_UNCERTAIN": ("error", "document"),
|
||||
"XLSB_SCALE_SUSPECT": ("error", "document"),
|
||||
"INN_NOT_FOUND": ("info", "document"),
|
||||
"INN_UNCERTAIN": ("info", "document"),
|
||||
"ORGANIZATION_NOT_FOUND": ("info", "document"),
|
||||
"ORGANIZATION_UNCERTAIN": ("info", "document"),
|
||||
"REPORT_DATE_UNPARSED": ("info", "document"),
|
||||
"UNITS_NOT_FOUND": ("info", "document"),
|
||||
"FILE_TYPE_MISMATCH": ("info", "file"),
|
||||
"COLUMN_SKIPPED": ("info", "document"),
|
||||
"FRACTIONAL_IN_THOUSANDS": ("info", "document"),
|
||||
"XLSB_NO_FORMATS": ("info", "document"),
|
||||
"NUMBER_FORMAT_UNPARSED": ("info", "document"),
|
||||
"VALUE_OUT_OF_RANGE": ("info", "document"),
|
||||
"SHEET_OTHER": ("info", "file"),
|
||||
"ROW_SPLIT": ("info", "document"),
|
||||
"XLS_NO_CODEPAGE": ("info", "file"),
|
||||
}
|
||||
|
||||
|
||||
def test_warning_table_matches_spec(rules: Rules) -> None:
|
||||
assert {c: (w.severity.value, w.scope) for c, w in rules.warnings.items()} == SPEC_WARNINGS
|
||||
|
||||
|
||||
def test_simplified_income_relation_is_soft(rules: Rules) -> None:
|
||||
(rule,) = rules.checks.relations_for(DocumentType.INCOME, FormVariant.SIMPLIFIED)
|
||||
assert rule.target == "2400" and not rule.strict
|
||||
assert rule.abs_terms == frozenset({"2120", "2330", "2350", "2410"})
|
||||
assert rules.checks.abs_codes(DocumentType.INCOME, FormVariant.FULL) == frozenset(
|
||||
{"2120", "2210", "2220", "2330", "2350", "2410"}
|
||||
)
|
||||
|
||||
|
||||
def test_code_orders_with_repeats(rules: Rules) -> None:
|
||||
fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.BALANCE, FormVariant.FULL)
|
||||
assert fsbu.count("1220") == len(("1220", "1220")) and "1120" not in fsbu
|
||||
ras = rules.code_orders.sequence(Edition.RAS_66N, DocumentType.BALANCE, FormVariant.FULL)
|
||||
assert "1120" in ras and ras.index("1110") < ras.index("1100")
|
||||
assert rules.code_orders.editions == (Edition.RAS_66N, Edition.FSBU4)
|
||||
assert rules.code_orders.sequence(Edition.FSBU4, DocumentType.DISCLOSURE, FormVariant.FULL) == ()
|
||||
|
||||
|
||||
def test_public_rule_types(rules: Rules) -> None:
|
||||
assert isinstance(rules.checks.tolerance, Decimal) and rules.checks.tolerance == Decimal("1")
|
||||
assert all(isinstance(e, Edition) for e in rules.code_orders.editions)
|
||||
fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.INCOME, FormVariant.SIMPLIFIED)
|
||||
assert isinstance(fsbu, tuple) and all(isinstance(c, str) for c in fsbu)
|
||||
assert (rules.code_ranges.min_codes_continuation, rules.code_ranges.min_codes) == (1, 3)
|
||||
assert tuple(rules.headers.labels) == LABEL_KEYS
|
||||
|
||||
|
||||
def test_warning_code_enum_matches_dictionary(rules: Rules) -> None:
|
||||
assert {c.value for c in WarningCode} == set(rules.warnings) == set(SPEC_WARNINGS)
|
||||
assert WarningCode.XLS_NO_CODEPAGE == "XLS_NO_CODEPAGE"
|
||||
|
||||
|
||||
def test_no_code_to_name_mapping_anywhere() -> None:
|
||||
code = re.compile(r"^\d{4,5}$")
|
||||
|
||||
def walk(node: object) -> None:
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
assert not (code.match(str(key)) and isinstance(value, str) and re.search(r"[А-Яа-яЁё]", value))
|
||||
walk(value)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
walk(item)
|
||||
|
||||
for path in RULES_DIR.glob("*.yaml"):
|
||||
walk(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def test_broken_repeat_count_rejected(tmp_path: Path) -> None:
|
||||
for path in RULES_DIR.glob("*.yaml"):
|
||||
(tmp_path / path.name).write_bytes(path.read_bytes())
|
||||
orders = tmp_path / "statement_code_orders.yaml"
|
||||
orders.write_text(
|
||||
orders.read_text(encoding="utf-8").replace('- "1520" # «Обязательство', '# "1520"'), encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(RulesError, match="statement_code_orders"):
|
||||
load_rules(tmp_path)
|
||||
|
||||
|
||||
def test_unknown_severity_rejected(tmp_path: Path) -> None:
|
||||
for path in RULES_DIR.glob("*.yaml"):
|
||||
(tmp_path / path.name).write_bytes(path.read_bytes())
|
||||
target = tmp_path / "statement_warnings.yaml"
|
||||
target.write_text(
|
||||
target.read_text(encoding="utf-8").replace("severity: info", "severity: warn", 1), encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(RulesError, match="statement_warnings"):
|
||||
load_rules(tmp_path)
|
||||
|
||||
|
||||
def test_make_warning_takes_severity_from_dictionary(rules: Rules) -> None:
|
||||
warning = make_warning(WarningCode.COLUMN_SKIPPED, "колонка «Изменение, %» пропущена", rules, column_index=5)
|
||||
assert (warning.code, warning.severity, warning.column_index) == ("COLUMN_SKIPPED", Severity.INFO, 5)
|
||||
assert make_warning("JSON_FAILED", "x", rules).severity is Severity.ERROR
|
||||
|
||||
|
||||
def test_make_warning_unknown_code_raises(rules: Rules) -> None:
|
||||
with pytest.raises(KeyError, match="NOT_A_CODE"):
|
||||
make_warning("NOT_A_CODE", "x", rules)
|
||||
|
||||
|
||||
def test_label_keys_are_checked(tmp_path: Path) -> None:
|
||||
for path in RULES_DIR.glob("*.yaml"):
|
||||
(tmp_path / path.name).write_bytes(path.read_bytes())
|
||||
target = tmp_path / "statement_headers.yaml"
|
||||
target.write_text(target.read_text(encoding="utf-8").replace(" okpo:", " okpo_x:", 1), encoding="utf-8")
|
||||
with pytest.raises(RulesError, match="statement_headers"):
|
||||
load_rules(tmp_path)
|
||||
Loading…
x
Reference in New Issue
Block a user