diff --git a/CLAUDE.md b/CLAUDE.md index eefb45c..094aecf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` + diff --git a/README.md b/README.md index 63a44eb..2ab2f94 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/api/rules/statement_checks.yaml b/api/rules/statement_checks.yaml deleted file mode 100644 index 317466c..0000000 --- a/api/rules/statement_checks.yaml +++ /dev/null @@ -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} diff --git a/api/src/contract/result.py b/api/src/contract/result.py index 1af260b..867d90d 100644 --- a/api/src/contract/result.py +++ b/api/src/contract/result.py @@ -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 diff --git a/api/src/domain/statement_aggregate.py b/api/src/domain/statement_aggregate.py index 590280f..3e01a9e 100644 --- a/api/src/domain/statement_aggregate.py +++ b/api/src/domain/statement_aggregate.py @@ -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 шаблонных строк).""" diff --git a/api/src/domain/statement_checks.py b/api/src/domain/statement_checks.py index ea50d21..25bd049 100644 --- a/api/src/domain/statement_checks.py +++ b/api/src/domain/statement_checks.py @@ -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\d{4})\s*=\s*(?P\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, diff --git a/api/tests/test_domain_statement_checks.py b/api/tests/test_domain_statement_checks.py index 586082e..255caa0 100644 --- a/api/tests/test_domain_statement_checks.py +++ b/api/tests/test_domain_statement_checks.py @@ -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 =")] != [] diff --git a/api/tests/test_domain_statement_lines.py b/api/tests/test_domain_statement_lines.py index 2c2ada7..94a4235 100644 --- a/api/tests/test_domain_statement_lines.py +++ b/api/tests/test_domain_statement_lines.py @@ -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"), [ diff --git a/openspec/changes/unified-normalization-core-b/design.md b/openspec/changes/unified-normalization-core-b/design.md index 35cfd2d..5f0901d 100644 --- a/openspec/changes/unified-normalization-core-b/design.md +++ b/openspec/changes/unified-normalization-core-b/design.md @@ -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 за рамки брифа | ответ формы и приём могут расходиться в редакции на формах без даты; существующее поведение сохранено |