refactor(statements): удалена копия словаря §7 ядра — проверки копией arm_statements

Удалены api/rules/statement_checks.yaml, load_check_rules, run_checks и правила соотношений; в
domain/statement_checks.py остались межформенная проверка 1370/2400 и subline_parents. Тест
test_name_dictionary_codes_in_code_orders сверяет справочник имён с перечнями редакций пакета.
CLAUDE.md и README.md: §7 — копия arm_statements, нарушения хранятся в document_issue. Решения P3-50…P3-52.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-18 22:08:27 +04:00
parent 2b8fafc3b3
commit 570e82b5d3
9 changed files with 65 additions and 443 deletions

View File

@ -26,7 +26,7 @@
ОСВ (§1.3, §1.5, §9.3); Срез 4 — `consolidation_periods.py` (выбор источников и приведение периодов, §2, Q1-эквивалентность ОСВ,
R-14), `consolidated_osv.py` (чистая сборка свода: склейка кварталов, пробелы, дорезолв, фильтры, §2–§3), `checklist.py`
(чек-лист комплекта, ячейки `CellStatus`); Срез 3b — `statement_mapping.py` (формы ББ/ОФР: период, черновик, статус D-6),
`statement_checks.py` (проверки §7 по словарю `api/rules/statement_checks.yaml`), `checklist_csv.py` (CSV чек-листа);
`statement_checks.py` (остатки §7 в ядре: межформенная 1370↔2400 и `subline_parents`; соотношения §7 считает `arm_statements`), `checklist_csv.py` (CSV чек-листа);
`policy.py` дополнен `IN_WORK_STATUSES`/`ensure_version_editable`, §10; для шахматки
и КФО — Срезы 5–6), `contract/` (модели контракта обмена), `api/scripts/` (`check_dbml_vs_orm.py`,
`seed.py` + `seed_lib/`, `check_infra.py`), данные seed — `api/seed/`.
@ -89,9 +89,12 @@
функции) + `repositories/statements.py` (`financial_statement_meta/column/line/value`); форма создаётся с тем, что есть в результате (колонки могут отсутствовать);
второй `BALANCE`/`INCOME` того же файла пропускается с предупреждением (OQ-77), `DISCLOSURE`/`OTHER` — один документ
без данных на тип (`RECOGNIZED`, D-3); документ `BALANCE`/`INCOME` без `meta` (создан до Среза 3b) подтверждается без условий D-9, `…/statement` по нему — 409
`DOCUMENT_NOT_STATEMENT` (решение №16). Проверки §7 — `domain/statement_checks.py` по словарю `api/rules/statement_checks.yaml`; **файл —
копия словаря tabular-parser, при изменении правится в обоих репозиториях** (расхождение молчаливо меняет результат проверок).
Проверки считаются на лету и не хранятся; хранится только акцепт (`checks_accepted_by/at/comment` в шапке). Правка формы и акцепт —
`DOCUMENT_NOT_STATEMENT` (решение №16). Проверки §7 — словарь и расчёт в пакете `arm_statements` (`api/arm_statements/`, копия, напрямую не править —
`../docs/scripts/sync-statements.sh`); **копии словаря §7 в ядре нет** (удалены `api/rules/statement_checks.yaml`,
`load_check_rules`, `run_checks`). Нарушения при приёме результата и после правки пишет пакет через
`services/statements_engine.py` и они **хранятся в `document_issue`** (`relation` / `soft`, `codes`, `line_ids`); ответ
формы читает их оттуда. В `domain/statement_checks.py` остались только межформенная проверка 1370↔2400 (на лету, допуск —
`engine.tolerance()`) и `subline_parents` (`parent_code` ответа). Акцепт — `checks_accepted_by/at/comment` в шапке. Правка формы и акцепт —
`services/statements.py` (`PATCH …/statement`, `POST …/statement/checks/accept`): **только до подтверждения** (`CONFIRMED` →
`DOCUMENT_READONLY`), аудит `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`, `object_type=document`; один запрос — одно событие), первичка
рядом (`original_amount`, `*_original_value`, `is_modified`/`*_modified`); любая правка сбрасывает акцепт и пересчитывает статус
@ -99,7 +102,8 @@
остаётся устаревший акцепт. Подтверждение формы (`services/documents._check_statement`) требует период (`PERIOD_REQUIRED`) и
пройденные/акцептованные жёсткие проверки (`CHECKS_NOT_PASSED`, `detail.failures`); мягкие (в т. ч. межформенная 1370↔2400)
не блокируют. Полный состав формы — `domain/statement_lines.py` по справочнику `api/rules/statement_lines.yaml` (редакции `66n`/`fsbu4`
из шаблонов банка; копия словаря scan-gateway с ним не совпадает): `GET …/statement` достраивает строки и ячейки с
из шаблонов банка; это справочник имён и состава экрана — перечни кодов редакций живут в `arm_statements`
(`Rules.code_orders`, `engine.editions()`/`engine.code_order(...)`), согласованность проверяет `test_name_dictionary_codes_in_code_orders`): `GET …/statement` достраивает строки и ячейки с
`id = null`, `PATCH` по `line_code` создаёт строку (`line_code_original_value = NULL` — «не из источника»); пустые
созданные строки не участвуют в §7 (`statement_amounts`). Решения — `openspec/changes/statement-template-lines/design.md`. Комментарии к документу — `services/documents.py` (`comments`/`add_comment`, `comment(object_type='document')`,
без аудита; `comments_count` в карточках). `GET /files/{id}/content` — `services/primary_files.content` +

View File

@ -563,9 +563,10 @@ S3 (storage); без S3 — предупреждение в логе, цикл
`modified_by/at`; у полей шапки — `*_modified`, `*_original_value`). Период формы — по дате отчёта, а при её отсутствии по самой
поздней дате колонок; ББ/ОФР всегда нарастающие. Статус документа формы — `RECOGNIZED` только если задание `done`, нет нарушений
проверок §7 (или они акцептованы), контрагент не `MISMATCH`, период определён, значения разобраны, есть строки и нет
предупреждений; иначе `NEEDS_CHECK` (правило D-6 спеки). Проверки §7 (`domain/statement_checks.py`) считаются на лету по
текущим суммам формы и не хранятся; словарь — `api/rules/statement_checks.yaml` (копия словаря tabular-parser — правится
в обоих репозиториях).
предупреждений; иначе `NEEDS_CHECK` (правило D-6 спеки). Проверки §7 считает пакет `arm_statements` (копия в `api/arm_statements/`,
обновляется `sync-statements`) при приёме результата и после каждой правки; нарушения хранятся в `document_issue`
(`relation` / `soft`). Копии словаря §7 в ядре нет; в `domain/statement_checks.py` остались межформенная проверка 1370↔2400
(на лету, допуск — из правил пакета) и `subline_parents`.
- **`GET /documents/{id}/statement`** — `{document, edition, header, columns[], lines[], checks, issues[], edition_lines[]}`: `header` — `inn`,
`report_date`, `units` вида `{value, original_value, confidence, verified, modified}` (`confidence` есть у ИНН, у даты и единиц — `null`) и справочные поля формы
@ -619,7 +620,8 @@ S3 (storage); без S3 — предупреждение в логе, цикл
Комментарии аудит не пишут; при удалении файла комментарии его документов удаляются вместе с документами
(полиморфная связь без FK; решение №18 закрыто).
Расчёт — `src/domain/statement_mapping.py` и `src/domain/statement_checks.py` (чистые функции), доступ к данным —
Расчёт — `src/domain/statement_mapping.py`, `src/domain/statement_checks.py` (межформенная проверка) и пакет
`arm_statements` через `src/services/statements_engine.py` (чистые функции), доступ к данным —
`src/repositories/statements.py`, правка и акцепт — `src/services/statements.py`.
## Удаление и замена
@ -1120,7 +1122,7 @@ make migration-cycle # проверить цикл upgr
api/
run_server.py точка входа (uvicorn; хост и порт — из настроек)
alembic/ миграции (0001_dbml_v5_13.py — модель v5.13, 0002_dbml_v5_14.py — v5.14, 0003_dbml_v5_15.py — v5.15, 0004_dbml_v5_16.py — v5.16, 0005_dbml_v5_17.py — v5.17, 0006_dbml_v5_18.py — v5.18, 0007_dbml_v5_19.py — v5.19)
rules/ statement_checks.yaml — словарь проверок §7 формы ББ/ОФР (копия словаря tabular-parser; меняется в обоих репозиториях)
rules/ statement_lines.yaml — справочник имён строк форм ББ/ОФР по редакциям (словарь проверок §7 — в пакете arm_statements)
seed/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист)
seed/dev/ dev-данные: филиалы и пользователи по ролям
scripts/ check_dbml_vs_orm.py (ORM <-> DBML), seed.py и seed_lib/ (загрузчик справочников)
@ -1143,7 +1145,7 @@ api/
common/ доменные ошибки (`{detail: {code, message}}`), часы
domain/ чистые функции по §§ правил, без БД: policy (видимость), directory_import (§13), aliases (§3), inn,
file_types (тип файла по расширению и сигнатуре), osv_mapping (результат парсера → документы/строки, §1.3, §1.5, §9.3);
Срез 3b — statement_mapping (формы ББ/ОФР: период, статус, §9.3, §9.5), statement_checks (проверки §7),
Срез 3b — statement_mapping (формы ББ/ОФР: период, статус, §9.3, §9.5), statement_checks (межформенная проверка §7, родители подстрок),
checklist_csv (CSV чек-листа);
Срез 4 — consolidation_periods (выбор источников, §2), consolidated_osv (свод), checklist (чек-лист), policy
(`ensure_version_editable`, §10)

View File

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

View File

@ -172,7 +172,7 @@ class FailedRelation(ContractModel):
"""
column_index: IntLike
relation: str # «1600 = 1100 + 1200» — текст из словаря statement_checks.yaml
relation: str # «1600 = 1100 + 1200» — текст из словаря §7 пакета arm_statements
expected: Money
actual: Money

View File

@ -74,7 +74,8 @@ class _Column(Protocol):
def checks_input(
columns: Sequence[_Column], aggregates: Mapping[str, CodeAggregate]
) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]:
"""Вход `run_checks`: индексы колонок (по возрастанию) и `(код, {column_index: сумма})` по возрастанию кода.
"""Коды формы для `subline_parents` и межформенной проверки: индексы колонок (по возрастанию) и
`(код, {column_index: сумма})` по возрастанию кода.
`columns` — колонки формы (`id`, `column_index`). Код без строки источника и без единой суммы (пустая шаблонная
строка) пропускается (design D-5 шаблонных строк)."""

View File

@ -1,152 +1,46 @@
"""Логические проверки форм ББ/ОФР §7 в ядре (spec statement-checks; design D-4, D-5).
"""Остатки проверок §7 в ядре: межформенная проверка и родители подстрок (spec statement-checks; design D-5).
Перенос `tabular-parser/api/src/parser/statements/checks.py` по семантике: соотношение по каждой колонке, только если
в форме есть строка его цели (пустая сумма `None` = 0: оба производителя отдают значение на каждую колонку каждой
строки); отсутствующий код и пустая сумма в правой части — 0; правая часть считается как записано и с `abs_terms` по
модулю, выполнено, если хоть один вариант в пределах `tolerance`; жёсткое нарушение не больше
`rounding_tolerance_percent` от наибольшего по модулю числа соотношения — мягкое; «сумма пятизначных подстрок =
четырёхзначный родитель» — мягкая и только если в колонке есть хоть одна непустая подстрока (AURORA-1527).
Результат не хранится (DBML).
Соотношения §7 по колонкам формы (в т. ч. «сумма пятизначных подстрок = четырёхзначный родитель») считает пакет
`arm_statements` при приёме и пересчёте; нарушения хранятся в `document_issue`. Копии словаря в ядре нет (часть Б, B6).
Здесь остались то, чего пакет не знает: межформенная проверка 1370 (ББ) ↔ 2400 (ОФР) — она по двум документам — и
привязка пятизначных подстрок к родителям для поля `parent_code` ответа формы (AURORA-1527).
"""
import functools
import re
from collections.abc import Mapping, Sequence
from collections.abc import Sequence
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Literal, cast
import yaml
from decimal import Decimal
__all__ = [
"CROSS_FORM_RELATION",
"CheckRules",
"ChecksReport",
"FailedCheck",
"RelationRule",
"cross_form_check",
"load_check_rules",
"run_checks",
"subline_parents",
]
Form = Literal["BALANCE", "INCOME"]
RULES_PATH = Path(__file__).resolve().parents[2] / "rules" / "statement_checks.yaml"
CROSS_FORM_RELATION = "1370 − 1370(начало года) = 2400"
# диапазоны четырёхзначных кодов форм для генерации подстрок (§12 п. 2; в tabular-parser —
# statement_headers.yaml)
CODE_RANGES: dict[str, tuple[int, int]] = {"BALANCE": (1100, 1799), "INCOME": (2100, 2999)}
_ZERO = Decimal("0")
_HUNDRED = Decimal("100")
_PARENT_LEN = 4
_GROUP_LEN = 3
_RELATION = re.compile(r"^\s*(?P<target>\d{4})\s*=\s*(?P<rhs>\d{4,5}(\s*[+\-]\s*\d{4,5})*)\s*$")
_TERM = re.compile(r"([+\-])?\s*(\d{4,5})")
@dataclass(frozen=True)
class RelationRule:
form: Form
relation: str
target: str
terms: tuple[tuple[int, str], ...]
strict: bool
abs_terms: frozenset[str] = frozenset()
@dataclass(frozen=True)
class CheckRules:
tolerance: Decimal
rounding_tolerance_percent: Decimal
subline_check: bool
relations: tuple[RelationRule, ...]
def relations_for(self, form: str) -> tuple[RelationRule, ...]:
return tuple(rule for rule in self.relations if rule.form == form)
@dataclass(frozen=True)
class FailedCheck:
"""Межформенное нарушение (`column_index = -1`); `codes` — коды для подсветки строк ответа (1370 и 2400)."""
column_index: int
relation: str
expected: Decimal
actual: Decimal
codes: tuple[str, ...] = () # цель и члены правила (P3-14); у межформенной — 1370 и 2400
@dataclass(frozen=True)
class ChecksReport:
failed: tuple[FailedCheck, ...]
soft_failed: tuple[FailedCheck, ...]
def _parse_relation(form: Form, relation: str, strict: bool, abs_terms: Sequence[str]) -> RelationRule:
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")))
unknown = set(abs_terms) - {code for _, code in terms}
if unknown:
raise ValueError(f"abs_terms {sorted(unknown)} нет в правой части «{relation}»")
return RelationRule(form, relation, found.group("target"), terms, strict, frozenset(abs_terms))
@functools.lru_cache(maxsize=4)
def load_check_rules(path: Path | None = None) -> CheckRules:
"""Словарь §7 из yaml; любая ошибка — `ValueError` с именем файла."""
source = path or RULES_PATH
try:
data = yaml.safe_load(source.read_text(encoding="utf-8"))
relations = tuple(
_parse_relation(item["form"], item["relation"], bool(item["strict"]), item.get("abs_terms", ()))
for item in data["relations"]
)
for rule in relations:
if rule.form not in CODE_RANGES:
raise ValueError(f"форма {rule.form!r}: ожидается BALANCE или INCOME")
return CheckRules(
tolerance=Decimal(str(data["tolerance"])),
rounding_tolerance_percent=Decimal(str(data.get("rounding_tolerance_percent", 0))),
subline_check=bool(data.get("subline_check", True)),
relations=relations,
)
except (OSError, KeyError, TypeError, ValueError, InvalidOperation, yaml.YAMLError) as exc:
raise ValueError(f"словарь проверок {source.name}: {exc}") from exc
def _rhs(amounts: Mapping[str, Decimal], rule: RelationRule, *, absolute: bool) -> Decimal:
total = _ZERO
for sign, code in rule.terms:
value = amounts.get(code, _ZERO)
if absolute and code in rule.abs_terms:
value = abs(value)
total += sign * value
return total
def _holds(amounts: Mapping[str, Decimal], rule: RelationRule, tolerance: Decimal) -> tuple[bool, Decimal, Decimal]:
expected = amounts.get(rule.target, _ZERO)
plain = _rhs(amounts, rule, absolute=False)
if abs(expected - plain) <= tolerance:
return True, expected, plain
if rule.abs_terms:
with_abs = _rhs(amounts, rule, absolute=True)
if abs(expected - with_abs) <= tolerance:
return True, expected, with_abs
if abs(expected - with_abs) < abs(expected - plain):
return False, expected, with_abs
return False, expected, plain
codes: tuple[str, ...] = ()
def subline_parents(codes: Sequence[str]) -> dict[str, str]:
"""Родитель каждой пятизначной подстроки: `{код подстроки: код четырёхзначной строки}` (единое правило §7 для
проверки «сумма подстрок = родитель» и для поля `parent_code` ответа формы; AURORA-1527).
"""Родитель каждой пятизначной подстроки: `{код подстроки: код четырёхзначной строки}` (поле `parent_code` ответа
формы; AURORA-1527).
Подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами, иначе — к строке с кодом из
её первых четырёх цифр, если такая есть; без родителя в результат не попадает. Порядок строк в БД не хранится
(DBML порядка не знает): «выше» — порядок `codes`, и вызывающий передаёт его тот же, в каком `checks_input`
отдаёт коды проверке (по возрастанию кода; M-9 2b). Порядок результата — порядок подстрок в `codes`."""
отдаёт коды (по возрастанию кода; M-9 2b). Порядок результата — порядок подстрок в `codes`."""
present = {code for code in codes if len(code) == _PARENT_LEN}
result: dict[str, str] = {}
last_parent: str | None = None
@ -163,66 +57,6 @@ def subline_parents(codes: Sequence[str]) -> dict[str, str]:
return result
def _subline_rules(codes: Sequence[str], form: str) -> list[RelationRule]:
"""«1110 = 11101 + …» для четырёхзначных кодов формы с пятизначными подстроками; родители — `subline_parents`."""
low, high = CODE_RANGES[form]
children: dict[str, list[str]] = {}
for kid, parent in subline_parents(codes).items():
children.setdefault(parent, []).append(kid)
rules: list[RelationRule] = []
for parent, kids in children.items():
if parent.isdigit() and low <= int(parent) <= high:
relation = f"{parent} = {' + '.join(kids)}"
rules.append(
RelationRule(
cast(Form, form),
relation,
parent,
tuple((1, kid) for kid in kids),
False,
)
)
return rules
def run_checks(
form: str,
lines: Sequence[tuple[str, Mapping[int, Decimal | None]]],
column_indexes: Sequence[int],
rules: CheckRules,
) -> ChecksReport:
"""Проверки §7 формы `form` по колонкам `column_indexes`; `lines` — `(код, {колонка: сумма})` в порядке кодов
(порядок строк формы в БД не хранится; от него зависит только привязка пятизначных подстрок)."""
explicit = list(rules.relations_for(form))
sublines = _subline_rules([code for code, _ in lines], form) if rules.subline_check else []
hard: list[FailedCheck] = []
soft: list[FailedCheck] = []
for column_index in column_indexes:
# присутствие цели = наличие строки формы; пустая сумма (None) = 0, как `_amounts` в tabular-parser: оба
# производителя отдают значение на каждую колонку каждой кодированной строки (пустая ячейка — amount=None)
amounts = {code: (values.get(column_index) or _ZERO) for code, values in lines}
filled = {code: values.get(column_index) is not None for code, values in lines}
for rule in [*explicit, *sublines]:
if rule not in sublines and rule.target not in amounts:
continue
# AURORA-1527: детализации нет (все подстроки родителя в колонке пусты, `None`) — «родитель = Σ подстрок»
# по колонке не проверяется; ноль — значение. Правило кода, словарь не меняется.
if rule in sublines and all(filled.get(code) is not True for _, code in rule.terms):
continue
holds, expected, actual = _holds(amounts, rule, rules.tolerance)
if holds:
continue
codes = (rule.target, *(code for _, code in rule.terms))
failed = FailedCheck(column_index, rule.relation, expected, actual, codes)
scale = max([abs(expected), *(abs(amounts.get(code, _ZERO)) for _, code in rule.terms)])
rounding = scale * rules.rounding_tolerance_percent / _HUNDRED
if rule.strict and abs(expected - actual) > rounding:
hard.append(failed)
else:
soft.append(failed)
return ChecksReport(failed=tuple(hard), soft_failed=tuple(soft))
def cross_form_check(
balance_1370: tuple[Decimal | None, Decimal | None],
income_2400: Decimal | None,

View File

@ -1,175 +1,27 @@
"""Проверки §7 ядра (spec statement-checks; design D-4, D-5): перенос проверок tabular-parser."""
"""Остатки проверок §7 в ядре: межформенная проверка и родители подстрок.
Остальные проверки §7 считает пакет `arm_statements` (тесты пакета и `test_statements_recompute*`); копии словаря
в ядре нет (часть Б, B6)."""
from decimal import Decimal
from pathlib import Path
import pytest
from src.domain.statement_checks import (
ChecksReport,
FailedCheck,
cross_form_check,
load_check_rules,
run_checks,
subline_parents,
)
from src.domain.statement_checks import FailedCheck, cross_form_check, subline_parents
D = Decimal
RULES = load_check_rules()
API_DIR = Path(__file__).resolve().parents[1]
def _lines(*items: tuple[str, dict[int, str | None]]) -> list[tuple[str, dict[int, Decimal | None]]]:
return [(code, {i: (D(v) if v is not None else None) for i, v in values.items()}) for code, values in items]
def test_rules_loaded_from_copy() -> None:
assert RULES.tolerance == D("1")
assert RULES.rounding_tolerance_percent == D("0.001")
relations = [rule.relation for rule in RULES.relations_for("BALANCE")]
assert "1600 = 1100 + 1200" in relations and "1600 = 1700" in relations
income = {rule.relation: rule for rule in RULES.relations_for("INCOME")}
assert income["2100 = 2110 - 2120"].abs_terms == frozenset({"2120"})
assert income["2400 = 2300 - 2410 + 2420"].strict is False
def test_hard_violation() -> None:
report = run_checks(
"BALANCE", _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "1000"})), [0], RULES
)
assert FailedCheck(0, "1600 = 1100 + 1200", D("1000"), D("900"), ("1600", "1100", "1200")) in report.failed
def test_relation_skipped_without_target() -> None:
report = run_checks("BALANCE", _lines(("1110", {0: "5"}), ("1150", {0: "7"})), [0], RULES)
assert report == ChecksReport(failed=(), soft_failed=())
def test_tolerance_one_unit() -> None:
report = run_checks("BALANCE", _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "901"})), [0], RULES)
assert all(item.relation != "1600 = 1100 + 1200" for item in report.failed)
def test_abs_terms_minus_sign() -> None:
report = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "-60"}), ("2100", {0: "40"})), [0], RULES)
assert all(item.relation != "2100 = 2110 - 2120" for item in report.failed)
def test_rounding_goes_soft() -> None:
# расхождение 5 при числах ~1 000 000: 5 ≤ 1 000 000 × 0,001 % = 10 → мягкое «в пределах округления»
report = run_checks(
"BALANCE", _lines(("1100", {0: "500000"}), ("1200", {0: "500000"}), ("1600", {0: "1000005"})), [0], RULES
)
assert all(item.relation != "1600 = 1100 + 1200" for item in report.failed)
assert any(item.relation == "1600 = 1100 + 1200" for item in report.soft_failed)
def test_soft_relation_not_hard() -> None:
report = run_checks("INCOME", _lines(("2300", {0: "100"}), ("2410", {0: "20"}), ("2400", {0: "50"})), [0], RULES)
assert all(item.relation != "2400 = 2300 - 2410 + 2420" for item in report.failed)
assert any(item.relation == "2400 = 2300 - 2410 + 2420" for item in report.soft_failed)
def test_sublines_soft() -> None:
report = run_checks("BALANCE", _lines(("1150", {0: "10"}), ("11501", {0: "6"}), ("11502", {0: "2"})), [0], RULES)
assert any(item.relation == "1150 = 11501 + 11502" for item in report.soft_failed)
assert report.failed == ()
def test_target_present_no_terms_hard_failure() -> None:
# Спека: отсутствующий код = 0; если цель присутствует но членов нет, это нарушение
# Правило 1600 = 1100 + 1200, в колонке только 1600=1000 → 1600 ≠ 0, жёсткое нарушение
report = run_checks("BALANCE", _lines(("1600", {0: "1000"})), [0], RULES)
assert any(
item.relation == "1600 = 1100 + 1200" and item.expected == D("1000") and item.actual == D("0")
for item in report.failed
)
def test_target_without_self_sum_hard_failure() -> None:
# Спека: пропуск только если цель отсутствует; если цель есть, правило проверяется
# Правило 1600 = 1700, в колонке только 1600=1000 (нет 1300, 1400, 1500) → нарушение
report = run_checks("BALANCE", _lines(("1600", {0: "1000"})), [0], RULES)
assert any(
item.relation == "1600 = 1700" and item.expected == D("1000") and item.actual == D("0")
for item in report.failed
)
def _by_relation(items: tuple[FailedCheck, ...], relation: str) -> list[FailedCheck]:
return [item for item in items if item.relation == relation]
def test_relation_checked_per_column_and_none_is_zero() -> None:
# колонка 0: 900 = 300 + 600; колонка 1: 1000 ≠ 900; колонка 2: 300 = 300 + None(=0)
lines = _lines(
("1100", {0: "300", 1: "300", 2: "300"}),
("1200", {0: "600", 1: "600", 2: None}),
("1600", {0: "900", 1: "1000", 2: "300"}),
)
report = run_checks("BALANCE", lines, [0, 1, 2], RULES)
assert _by_relation(report.failed, "1600 = 1100 + 1200") == [
FailedCheck(1, "1600 = 1100 + 1200", D("1000"), D("900"), ("1600", "1100", "1200"))
]
assert _by_relation(report.soft_failed, "1600 = 1100 + 1200") == []
def test_tolerance_exceeded_by_one_unit_is_violation() -> None:
# допуск 1: 902 при 300 + 600 — расхождение 2 > 1, число мало, округление (0,001 %) не спасает
lines = _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "902"}))
report = run_checks("BALANCE", lines, [0], RULES)
assert _by_relation(report.failed, "1600 = 1100 + 1200") == [
FailedCheck(0, "1600 = 1100 + 1200", D("902"), D("900"), ("1600", "1100", "1200"))
]
def test_tolerance_boundary_is_inclusive() -> None:
lines = _lines(("1100", {0: "300"}), ("1200", {0: "600"}), ("1600", {0: "901"}))
report = run_checks("BALANCE", lines, [0], RULES)
assert _by_relation(report.failed, "1600 = 1100 + 1200") == []
assert _by_relation(report.soft_failed, "1600 = 1100 + 1200") == []
def test_abs_terms_positive_expense_holds_and_mismatch_fails() -> None:
# 66н пишет расход в скобках, 1С — со знаком минус; здесь положительный 2120: 100 − 60 = 40 выполнено по модулю
holds = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "60"}), ("2100", {0: "40"})), [0], RULES)
assert _by_relation(holds.failed, "2100 = 2110 - 2120") == []
broken = run_checks("INCOME", _lines(("2110", {0: "100"}), ("2120", {0: "60"}), ("2100", {0: "50"})), [0], RULES)
failed = _by_relation(broken.failed, "2100 = 2110 - 2120")
assert len(failed) == 1
assert (failed[0].column_index, failed[0].expected, failed[0].actual) == (0, D("50"), D("40"))
def test_subline_attached_to_last_parent_by_row_order() -> None:
# M-9: 12310 и 12320 идут под строкой 1230, хотя префикса 1231/1232 среди четырёхзначных кодов нет
lines = _lines(("1230", {0: "10"}), ("12310", {0: "6"}), ("12320", {0: "2"}))
report = run_checks("BALANCE", lines, [0], RULES)
assert [item.relation for item in _by_relation(report.soft_failed, "1230 = 12310 + 12320")] == [
"1230 = 12310 + 12320"
]
def test_subline_prefers_last_parent_over_prefix() -> None:
# 12310 стоит под 1230 (ближайшая строка выше с теми же тремя цифрами), а не под 1231, хотя префикс 1231 есть
lines = _lines(("1231", {0: "5"}), ("1230", {0: "10"}), ("12310", {0: "6"}), ("12320", {0: "2"}))
report = run_checks("BALANCE", lines, [0], RULES)
relations = {item.relation for item in report.soft_failed}
assert "1230 = 12310 + 12320" in relations
assert not any(relation.startswith("1231 =") for relation in relations)
def test_rounding_threshold_boundary() -> None:
# порог = 0,001 % наибольшего по модулю числа соотношения; при итоге 10 000 000 он ровно 100
assert D("10000000") * RULES.rounding_tolerance_percent / D("100") == D("100")
halves = {0: "4999950"}
at_threshold = run_checks(
"BALANCE", _lines(("1100", halves), ("1200", halves), ("1600", {0: "10000000"})), [0], RULES
)
assert _by_relation(at_threshold.failed, "1600 = 1100 + 1200") == []
assert [item.expected - item.actual for item in _by_relation(at_threshold.soft_failed, "1600 = 1100 + 1200")] == [
D("100")
]
above = run_checks("BALANCE", _lines(("1100", halves), ("1200", halves), ("1600", {0: "10000001"})), [0], RULES)
assert [item.expected - item.actual for item in _by_relation(above.failed, "1600 = 1100 + 1200")] == [D("101")]
assert _by_relation(above.soft_failed, "1600 = 1100 + 1200") == []
def test_no_core_check_rules() -> None:
"""Копии словаря §7 и загрузчика/исполнителя правил в ядре нет (B6)."""
assert not (API_DIR / "rules" / "statement_checks.yaml").exists()
forbidden = ("load_check_rules", "run_checks", "statement_checks.yaml")
for source in (API_DIR / "src").rglob("*.py"):
text = source.read_text(encoding="utf-8")
for name in forbidden:
assert name not in text, f"{source.relative_to(API_DIR)}: {name}"
@pytest.mark.parametrize(
@ -191,26 +43,6 @@ def test_cross_form(
assert cross_form_check(b1370, i2400, D("1")) == expected
def test_broken_rules_file(tmp_path) -> None:
bad = tmp_path / "statement_checks.yaml"
bad.write_text("tolerance: 1\nrelations:\n - {form: BALANCE, relation: '16A0 = 1100', strict: true}\n", "utf-8")
with pytest.raises(ValueError, match="statement_checks.yaml"):
load_check_rules(bad)
def test_target_with_empty_cell_counts_as_zero_and_is_checked() -> None:
"""M-1 (откат): цель присутствует, если строка есть в форме; пустая ячейка (`None`) = 0 — как в tabular-parser
(`_amounts`, null -> 0): оба производителя отдают значение на каждую колонку каждой кодированной строки, пустая
или нераспознанная ячейка итога при ненулевых слагаемых — жёсткое нарушение (а не пропуск правила)."""
terms = (("1100", {0: "300", 1: "300"}), ("1200", {0: "600", 1: "600"}))
report = run_checks("BALANCE", _lines(*terms, ("1600", {0: None, 1: "0"})), [0, 1], RULES)
failed = _by_relation(report.failed, "1600 = 1100 + 1200")
assert [(item.column_index, item.expected, item.actual) for item in failed] == [
(0, D("0"), D("900")),
(1, D("0"), D("900")),
]
def test_subline_parents_nearest_parent_above_with_same_group() -> None:
# 12310 и 12320 относятся к 1230 (ближайшая выше с теми же тремя цифрами), а не к 1231
codes = ["1231", "1230", "12310", "12320"]
@ -232,42 +64,3 @@ def test_subline_parents_follow_given_order() -> None:
# порядок задаёт вызывающий (порядок кодов `statement_amounts`): «ближайшая выше» считается по нему
assert subline_parents(["1230", "1231", "12310"]) == {"12310": "1231"}
assert subline_parents(["1231", "1230", "12310"]) == {"12310": "1230"}
def test_run_checks_and_subline_parents_agree() -> None:
# проверка «сумма подстрок = родитель» строится по тем же родителям, что и `subline_parents`
codes = ["1231", "1230", "12310", "12320", "1110", "11101"]
amounts = {"11101": "5", "1230": "10"}
report = run_checks("BALANCE", _lines(*((code, {0: amounts.get(code, "1")}) for code in codes)), [0], RULES)
parents = subline_parents(codes)
expected = {
f"{parent} = {' + '.join(kid for kid, owner in parents.items() if owner == parent)}"
for parent in set(parents.values())
}
assert {item.relation for item in report.soft_failed} == expected
def test_subline_check_skipped_when_all_sublines_empty_in_column() -> None:
# AURORA-1527: детализации нет (все подстроки пусты) — «1150 = 11501 + …» по колонке не проверяется
lines = _lines(("1150", {0: "90", 1: "90"}), ("11501", {0: None, 1: None}), ("11502", {0: None, 1: None}))
assert _by_relation(run_checks("BALANCE", lines, [0, 1], RULES).soft_failed, "1150 = 11501 + 11502") == []
def test_subline_check_is_per_column() -> None:
# в колонке 0 подстроки пусты (не проверяется), в колонке 1 заполнены и не сходятся (нарушение)
lines = _lines(("1150", {0: "90", 1: "90"}), ("11501", {0: None, 1: "10"}), ("11502", {0: None, 1: None}))
failed = _by_relation(run_checks("BALANCE", lines, [0, 1], RULES).soft_failed, "1150 = 11501 + 11502")
assert [(f.column_index, f.expected, f.actual) for f in failed] == [(1, D("90"), D("10"))]
def test_subline_zero_is_a_value() -> None:
# ноль — значение: подстроки с нулями проверяются как раньше
lines = _lines(("1150", {0: "90"}), ("11501", {0: "0"}), ("11502", {0: None}))
assert len(_by_relation(run_checks("BALANCE", lines, [0], RULES).soft_failed, "1150 = 11501 + 11502")) == 1
def test_empty_sublines_do_not_affect_other_relations() -> None:
# прочие соотношения §7 не затронуты: пустая цель 1200 при ненулевых слагаемых — по-прежнему жёсткое нарушение
lines = _lines(("1210", {0: "5"}), ("12101", {0: None}), ("1200", {0: None}), ("1600", {0: "5"}))
report = run_checks("BALANCE", lines, [0], RULES)
assert [f.relation for f in report.failed if f.relation.startswith("1200 =")] != []

View File

@ -5,10 +5,24 @@ from pathlib import Path
import pytest
from src.domain.statement_lines import TemplateLine, load_line_catalog
from src.services.statements_engine import DocumentType, FormVariant, default_engine
CATALOG = load_line_catalog()
def test_name_dictionary_codes_in_code_orders() -> None:
"""Редакции справочника имён — ровно редакции пакета, каждый код имени есть в перечне этой редакции копии пакета
(любой вариант формы, B6; перечни — `Rules.code_orders`)."""
engine = default_engine()
editions = engine.editions()
assert tuple(CATALOG.editions) == tuple(edition.value for edition in editions)
for edition in editions:
for form, document_type in (("BALANCE", DocumentType.BALANCE), ("INCOME", DocumentType.INCOME)):
in_orders = {code for variant in FormVariant for code in engine.code_order(edition, document_type, variant)}
missing = CATALOG.edition_codes(edition.value, form) - in_orders
assert not missing, f"{edition.value}/{form}: коды справочника имён вне перечня пакета: {sorted(missing)}"
@pytest.mark.parametrize(
("edition", "form", "count", "first", "last"),
[

View File

@ -58,3 +58,6 @@ Goals и Non-goals — см. `proposal.md`.
| 2026-10-18 | **P3-47 (B5, T5.2 части А).** Нечитаемый JSON (`JSONDecodeError`, не UTF-8) в `_load_result` — тоже `MappingFatal` с текстом «результат не соответствует схеме recognition-result: файл не является JSON» (текст исключения парсера пользователю не показывается; M-1); не-объект (`[]`, `null`) отсекается моделью как нарушение схемы | иначе `ValueError` из `parse_result` уходил бы в повторяемые ошибки и файл `ERROR` получал бы только после `MAPPING_MAX_ATTEMPTS` попыток | ошибка JSON не отличается в тексте от прочих ошибок схемы |
| 2026-10-18 | **P3-48 (B5).** Статус ОСВ `2.0` — `osv_status_v2(osv_done = sheet.status == "done", checks_ok, file_error_warning, counterparty, sheet_mapping_warnings)`; «предупреждения маппинга листа» = повтор счёта + сироты + «нарастающая по данным Q1» (вход `document_status` `1.x` тот же); у `1.x` статус как прежде (§9.3, статус задания). Предупреждение уровня `error` ищется в `metrics.warnings` результата (`file_warnings`), а не в `recognition_job.metrics` | Р-28; единый источник с формами | ОСВ с `error` в метриках файла — `NEEDS_CHECK` |
| 2026-10-18 | **P3-49 (B5).** `osvDocument.warnings` пишутся в `document_issue` как `warning` / `service` с `severity` из предупреждения (по контракту `info`, `error` не отбрасывается); `line_refs` у ОСВ не сохраняются (строк формы нет), `column_index` и `field` — как пришли. Записи сервиса идут раньше записей маппинга ядра. Дубль листа (повтор счёта) своих предупреждений/статуса не вносит — документом остаётся первый лист | у ОСВ нет `line_ids`/колонок формы; повтор — D-7 | `error`-предупреждение листа ОСВ в `issues` не влияет на статус само по себе (статус — по `status` листа) |
| 2026-10-18 | **P3-50 (B6).** `FailedCheck.codes` в ядре остаётся (вопреки плану, который удалял поле): оно нужно межформенной проверке 1370↔2400 (`codes = ("1370", "2400")`) для `codes` / `line_ids` в `checks.cross_form` ответа; нарушения §7 по колонкам читаются из `document_issue`, где коды хранятся сами | межформенную проверку пакет не считает (она по двум документам), без `codes` подсветка строк в ответе пропала бы | у `cross_form` не было бы `codes`/`line_ids`, фронт не подсвечивал бы строки 1370 и 2400 |
| 2026-10-18 | **P3-51 (B6).** `checks_report` уже брал допуск у `engine.tolerance()` (с B2), поэтому правки сервиса в B6 нет; константа допуска ядра удалена вместе с `load_check_rules`. Состав и порядок редакций справочника имён `statement_lines.yaml` не переписывается на `engine.editions()` в коде: согласованность проверяет тест `test_name_dictionary_codes_in_code_orders` (редакции справочника = редакции пакета в том же порядке; каждый код справочника есть в перечне редакции пакета, любой вариант формы) | справочник остаётся данными имён и состава экрана; перечни кодов — единственный источник в пакете, расхождение ловит тест, а не молчаливая подмена в рантайме | при добавлении редакции в пакет без справочника имён тест красный; в рантайме новая редакция дала бы форму без шаблонных строк |
| 2026-10-18 | **P3-52 (B6).** `edition_for` (граница 66n/fsbu4 по дате для шаблона экрана) в `domain/statement_mapping.py` не тронут: переход на `engine.edition(...)` требует кодов формы и варианта и относится к ответу формы (B7/доработка), а не к удалению копии словаря | не расширять B6 за рамки брифа | ответ формы и приём могут расходиться в редакции на формах без даты; существующее поведение сохранено |