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-эквивалентность ОСВ, ОСВ (§1.3, §1.5, §9.3); Срез 4 — `consolidation_periods.py` (выбор источников и приведение периодов, §2, Q1-эквивалентность ОСВ,
R-14), `consolidated_osv.py` (чистая сборка свода: склейка кварталов, пробелы, дорезолв, фильтры, §2–§3), `checklist.py` R-14), `consolidated_osv.py` (чистая сборка свода: склейка кварталов, пробелы, дорезолв, фильтры, §2–§3), `checklist.py`
(чек-лист комплекта, ячейки `CellStatus`); Срез 3b — `statement_mapping.py` (формы ББ/ОФР: период, черновик, статус D-6), (чек-лист комплекта, ячейки `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; для шахматки `policy.py` дополнен `IN_WORK_STATUSES`/`ensure_version_editable`, §10; для шахматки
и КФО — Срезы 5–6), `contract/` (модели контракта обмена), `api/scripts/` (`check_dbml_vs_orm.py`, и КФО — Срезы 5–6), `contract/` (модели контракта обмена), `api/scripts/` (`check_dbml_vs_orm.py`,
`seed.py` + `seed_lib/`, `check_infra.py`), данные seed — `api/seed/`. `seed.py` + `seed_lib/`, `check_infra.py`), данные seed — `api/seed/`.
@ -89,9 +89,12 @@
функции) + `repositories/statements.py` (`financial_statement_meta/column/line/value`); форма создаётся с тем, что есть в результате (колонки могут отсутствовать); функции) + `repositories/statements.py` (`financial_statement_meta/column/line/value`); форма создаётся с тем, что есть в результате (колонки могут отсутствовать);
второй `BALANCE`/`INCOME` того же файла пропускается с предупреждением (OQ-77), `DISCLOSURE`/`OTHER` — один документ второй `BALANCE`/`INCOME` того же файла пропускается с предупреждением (OQ-77), `DISCLOSURE`/`OTHER` — один документ
без данных на тип (`RECOGNIZED`, D-3); документ `BALANCE`/`INCOME` без `meta` (создан до Среза 3b) подтверждается без условий D-9, `…/statement` по нему — 409 без данных на тип (`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`; **файл — `DOCUMENT_NOT_STATEMENT` (решение №16). Проверки §7 — словарь и расчёт в пакете `arm_statements` (`api/arm_statements/`, копия, напрямую не править —
копия словаря tabular-parser, при изменении правится в обоих репозиториях** (расхождение молчаливо меняет результат проверок). `../docs/scripts/sync-statements.sh`); **копии словаря §7 в ядре нет** (удалены `api/rules/statement_checks.yaml`,
Проверки считаются на лету и не хранятся; хранится только акцепт (`checks_accepted_by/at/comment` в шапке). Правка формы и акцепт — `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` → `services/statements.py` (`PATCH …/statement`, `POST …/statement/checks/accept`): **только до подтверждения** (`CONFIRMED` →
`DOCUMENT_READONLY`), аудит `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`, `object_type=document`; один запрос — одно событие), первичка `DOCUMENT_READONLY`), аудит `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`, `object_type=document`; один запрос — одно событие), первичка
рядом (`original_amount`, `*_original_value`, `is_modified`/`*_modified`); любая правка сбрасывает акцепт и пересчитывает статус рядом (`original_amount`, `*_original_value`, `is_modified`/`*_modified`); любая правка сбрасывает акцепт и пересчитывает статус
@ -99,7 +102,8 @@
остаётся устаревший акцепт. Подтверждение формы (`services/documents._check_statement`) требует период (`PERIOD_REQUIRED`) и остаётся устаревший акцепт. Подтверждение формы (`services/documents._check_statement`) требует период (`PERIOD_REQUIRED`) и
пройденные/акцептованные жёсткие проверки (`CHECKS_NOT_PASSED`, `detail.failures`); мягкие (в т. ч. межформенная 1370↔2400) пройденные/акцептованные жёсткие проверки (`CHECKS_NOT_PASSED`, `detail.failures`); мягкие (в т. ч. межформенная 1370↔2400)
не блокируют. Полный состав формы — `domain/statement_lines.py` по справочнику `api/rules/statement_lines.yaml` (редакции `66n`/`fsbu4` не блокируют. Полный состав формы — `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` — «не из источника»); пустые `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')`, созданные строки не участвуют в §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` + без аудита; `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`). Период формы — по дате отчёта, а при её отсутствии по самой `modified_by/at`; у полей шапки — `*_modified`, `*_original_value`). Период формы — по дате отчёта, а при её отсутствии по самой
поздней дате колонок; ББ/ОФР всегда нарастающие. Статус документа формы — `RECOGNIZED` только если задание `done`, нет нарушений поздней дате колонок; ББ/ОФР всегда нарастающие. Статус документа формы — `RECOGNIZED` только если задание `done`, нет нарушений
проверок §7 (или они акцептованы), контрагент не `MISMATCH`, период определён, значения разобраны, есть строки и нет проверок §7 (или они акцептованы), контрагент не `MISMATCH`, период определён, значения разобраны, есть строки и нет
предупреждений; иначе `NEEDS_CHECK` (правило D-6 спеки). Проверки §7 (`domain/statement_checks.py`) считаются на лету по предупреждений; иначе `NEEDS_CHECK` (правило D-6 спеки). Проверки §7 считает пакет `arm_statements` (копия в `api/arm_statements/`,
текущим суммам формы и не хранятся; словарь — `api/rules/statement_checks.yaml` (копия словаря tabular-parser — правится обновляется `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`, - **`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`) и справочные поля формы `report_date`, `units` вида `{value, original_value, confidence, verified, modified}` (`confidence` есть у ИНН, у даты и единиц — `null`) и справочные поля формы
@ -619,7 +620,8 @@ S3 (storage); без S3 — предупреждение в логе, цикл
Комментарии аудит не пишут; при удалении файла комментарии его документов удаляются вместе с документами Комментарии аудит не пишут; при удалении файла комментарии его документов удаляются вместе с документами
(полиморфная связь без FK; решение №18 закрыто). (полиморфная связь без 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`. `src/repositories/statements.py`, правка и акцепт — `src/services/statements.py`.
## Удаление и замена ## Удаление и замена
@ -1120,7 +1122,7 @@ make migration-cycle # проверить цикл upgr
api/ api/
run_server.py точка входа (uvicorn; хост и порт — из настроек) 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) 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/core/ справочники продукта (роли, права, филиалы, план счетов, правила элиминации, чек-лист)
seed/dev/ dev-данные: филиалы и пользователи по ролям seed/dev/ dev-данные: филиалы и пользователи по ролям
scripts/ check_dbml_vs_orm.py (ORM <-> DBML), seed.py и seed_lib/ (загрузчик справочников) scripts/ check_dbml_vs_orm.py (ORM <-> DBML), seed.py и seed_lib/ (загрузчик справочников)
@ -1143,7 +1145,7 @@ api/
common/ доменные ошибки (`{detail: {code, message}}`), часы common/ доменные ошибки (`{detail: {code, message}}`), часы
domain/ чистые функции по §§ правил, без БД: policy (видимость), directory_import (§13), aliases (§3), inn, domain/ чистые функции по §§ правил, без БД: policy (видимость), directory_import (§13), aliases (§3), inn,
file_types (тип файла по расширению и сигнатуре), osv_mapping (результат парсера → документы/строки, §1.3, §1.5, §9.3); 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 чек-листа); checklist_csv (CSV чек-листа);
Срез 4 — consolidation_periods (выбор источников, §2), consolidated_osv (свод), checklist (чек-лист), policy Срез 4 — consolidation_periods (выбор источников, §2), consolidated_osv (свод), checklist (чек-лист), policy
(`ensure_version_editable`, §10) (`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 column_index: IntLike
relation: str # «1600 = 1100 + 1200» — текст из словаря statement_checks.yaml relation: str # «1600 = 1100 + 1200» — текст из словаря §7 пакета arm_statements
expected: Money expected: Money
actual: Money actual: Money

View File

@ -74,7 +74,8 @@ class _Column(Protocol):
def checks_input( def checks_input(
columns: Sequence[_Column], aggregates: Mapping[str, CodeAggregate] columns: Sequence[_Column], aggregates: Mapping[str, CodeAggregate]
) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]: ) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]:
"""Вход `run_checks`: индексы колонок (по возрастанию) и `(код, {column_index: сумма})` по возрастанию кода. """Коды формы для `subline_parents` и межформенной проверки: индексы колонок (по возрастанию) и
`(код, {column_index: сумма})` по возрастанию кода.
`columns` — колонки формы (`id`, `column_index`). Код без строки источника и без единой суммы (пустая шаблонная `columns` — колонки формы (`id`, `column_index`). Код без строки источника и без единой суммы (пустая шаблонная
строка) пропускается (design D-5 шаблонных строк).""" строка) пропускается (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` по семантике: соотношение по каждой колонке, только если Соотношения §7 по колонкам формы (в т. ч. «сумма пятизначных подстрок = четырёхзначный родитель») считает пакет
в форме есть строка его цели (пустая сумма `None` = 0: оба производителя отдают значение на каждую колонку каждой `arm_statements` при приёме и пересчёте; нарушения хранятся в `document_issue`. Копии словаря в ядре нет (часть Б, B6).
строки); отсутствующий код и пустая сумма в правой части — 0; правая часть считается как записано и с `abs_terms` по Здесь остались то, чего пакет не знает: межформенная проверка 1370 (ББ) ↔ 2400 (ОФР) — она по двум документам — и
модулю, выполнено, если хоть один вариант в пределах `tolerance`; жёсткое нарушение не больше привязка пятизначных подстрок к родителям для поля `parent_code` ответа формы (AURORA-1527).
`rounding_tolerance_percent` от наибольшего по модулю числа соотношения — мягкое; «сумма пятизначных подстрок =
четырёхзначный родитель» — мягкая и только если в колонке есть хоть одна непустая подстрока (AURORA-1527).
Результат не хранится (DBML).
""" """
import functools from collections.abc import Sequence
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal, InvalidOperation from decimal import Decimal
from pathlib import Path
from typing import Literal, cast
import yaml
__all__ = [ __all__ = [
"CROSS_FORM_RELATION", "CROSS_FORM_RELATION",
"CheckRules",
"ChecksReport",
"FailedCheck", "FailedCheck",
"RelationRule",
"cross_form_check", "cross_form_check",
"load_check_rules",
"run_checks",
"subline_parents", "subline_parents",
] ]
Form = Literal["BALANCE", "INCOME"]
RULES_PATH = Path(__file__).resolve().parents[2] / "rules" / "statement_checks.yaml"
CROSS_FORM_RELATION = "1370 − 1370(начало года) = 2400" 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 _PARENT_LEN = 4
_GROUP_LEN = 3 _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) @dataclass(frozen=True)
class FailedCheck: class FailedCheck:
"""Межформенное нарушение (`column_index = -1`); `codes` — коды для подсветки строк ответа (1370 и 2400)."""
column_index: int column_index: int
relation: str relation: str
expected: Decimal expected: Decimal
actual: Decimal actual: Decimal
codes: tuple[str, ...] = () # цель и члены правила (P3-14); у межформенной — 1370 и 2400 codes: tuple[str, ...] = ()
@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
def subline_parents(codes: Sequence[str]) -> dict[str, str]: def subline_parents(codes: Sequence[str]) -> dict[str, str]:
"""Родитель каждой пятизначной подстроки: `{код подстроки: код четырёхзначной строки}` (единое правило §7 для """Родитель каждой пятизначной подстроки: `{код подстроки: код четырёхзначной строки}` (поле `parent_code` ответа
проверки «сумма подстрок = родитель» и для поля `parent_code` ответа формы; AURORA-1527). формы; AURORA-1527).
Подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами, иначе — к строке с кодом из Подстрока относится к ближайшей четырёхзначной строке выше с теми же тремя цифрами, иначе — к строке с кодом из
её первых четырёх цифр, если такая есть; без родителя в результат не попадает. Порядок строк в БД не хранится её первых четырёх цифр, если такая есть; без родителя в результат не попадает. Порядок строк в БД не хранится
(DBML порядка не знает): «выше» — порядок `codes`, и вызывающий передаёт его тот же, в каком `checks_input` (DBML порядка не знает): «выше» — порядок `codes`, и вызывающий передаёт его тот же, в каком `checks_input`
отдаёт коды проверке (по возрастанию кода; M-9 2b). Порядок результата — порядок подстрок в `codes`.""" отдаёт коды (по возрастанию кода; M-9 2b). Порядок результата — порядок подстрок в `codes`."""
present = {code for code in codes if len(code) == _PARENT_LEN} present = {code for code in codes if len(code) == _PARENT_LEN}
result: dict[str, str] = {} result: dict[str, str] = {}
last_parent: str | None = None last_parent: str | None = None
@ -163,66 +57,6 @@ def subline_parents(codes: Sequence[str]) -> dict[str, str]:
return result 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( def cross_form_check(
balance_1370: tuple[Decimal | None, Decimal | None], balance_1370: tuple[Decimal | None, Decimal | None],
income_2400: 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 decimal import Decimal
from pathlib import Path
import pytest import pytest
from src.domain.statement_checks import ( from src.domain.statement_checks import FailedCheck, cross_form_check, subline_parents
ChecksReport,
FailedCheck,
cross_form_check,
load_check_rules,
run_checks,
subline_parents,
)
D = Decimal 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]]]: def test_no_core_check_rules() -> None:
return [(code, {i: (D(v) if v is not None else None) for i, v in values.items()}) for code, values in items] """Копии словаря §7 и загрузчика/исполнителя правил в ядре нет (B6)."""
assert not (API_DIR / "rules" / "statement_checks.yaml").exists()
forbidden = ("load_check_rules", "run_checks", "statement_checks.yaml")
def test_rules_loaded_from_copy() -> None: for source in (API_DIR / "src").rglob("*.py"):
assert RULES.tolerance == D("1") text = source.read_text(encoding="utf-8")
assert RULES.rounding_tolerance_percent == D("0.001") for name in forbidden:
relations = [rule.relation for rule in RULES.relations_for("BALANCE")] assert name not in text, f"{source.relative_to(API_DIR)}: {name}"
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") == []
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -191,26 +43,6 @@ def test_cross_form(
assert cross_form_check(b1370, i2400, D("1")) == expected 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: def test_subline_parents_nearest_parent_above_with_same_group() -> None:
# 12310 и 12320 относятся к 1230 (ближайшая выше с теми же тремя цифрами), а не к 1231 # 12310 и 12320 относятся к 1230 (ближайшая выше с теми же тремя цифрами), а не к 1231
codes = ["1231", "1230", "12310", "12320"] codes = ["1231", "1230", "12310", "12320"]
@ -232,42 +64,3 @@ def test_subline_parents_follow_given_order() -> None:
# порядок задаёт вызывающий (порядок кодов `statement_amounts`): «ближайшая выше» считается по нему # порядок задаёт вызывающий (порядок кодов `statement_amounts`): «ближайшая выше» считается по нему
assert subline_parents(["1230", "1231", "12310"]) == {"12310": "1231"} assert subline_parents(["1230", "1231", "12310"]) == {"12310": "1231"}
assert subline_parents(["1231", "1230", "12310"]) == {"12310": "1230"} 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 import pytest
from src.domain.statement_lines import TemplateLine, load_line_catalog from src.domain.statement_lines import TemplateLine, load_line_catalog
from src.services.statements_engine import DocumentType, FormVariant, default_engine
CATALOG = load_line_catalog() 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( @pytest.mark.parametrize(
("edition", "form", "count", "first", "last"), ("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-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-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-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 за рамки брифа | ответ формы и приём могут расходиться в редакции на формах без даты; существующее поведение сохранено |