diff --git a/Makefile b/Makefile index c494bc7..8e9c3e4 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ VENV := .venv PY := $(if $(wildcard $(VENV)/bin/python),$(CURDIR)/$(VENV)/bin/python,python3) CHANGE ?= slice-0-skeleton # Текущая версия модели данных — по ней рисуются диаграммы (make diagrams) -DBML ?= model/data-model-v5.18.dbml +DBML ?= model/data-model-v5.19.dbml .PHONY: venv validate-contracts validate-openspec test-snapshot test-sync-web test-sync-statements sync-web sync-statements statements-check check diagrams @@ -14,7 +14,7 @@ venv: uv venv --python 3.11 --allow-existing $(VENV) uv pip install --python $(VENV)/bin/python jsonschema pyyaml -# Примеры contracts/examples/ против трёх схем JSON Schema 2020-12 +# Примеры contracts/examples/ (и examples/v1/ окна перехода) против схем JSON Schema 2020-12 + перечни кодов форм validate-contracts: $(PY) scripts/validate_contracts.py diff --git a/context/00-workspace-layout.md b/context/00-workspace-layout.md index 34edae7..45213f8 100644 --- a/context/00-workspace-layout.md +++ b/context/00-workspace-layout.md @@ -15,6 +15,7 @@ ├── arm-core/ → git: arm-core ядро: FastAPI + React (монорепо по шаблону команды) ├── tabular-parser/ → git: arm-tabular-parser сервис разбора XLS/XLSX/XLSB (ОСВ, ББ, ОФР) ├── scan-gateway/ → git: arm-scan-gateway адаптер ИС Сканирование (PDF/PNG/JPG) +├── arm-statements/ → git: arm-statements пакет общего кода нормализации ББ/ОФР `arm_statements` (копируется в сервисы и ядро; план 1 change unified-statement-normalization) └── .claude/ ← настройки workspace: хуки, разрешения (не в git или в arm-ka-docs/.claude-workspace) ``` @@ -37,7 +38,7 @@ связанные PR в других репозиториях. - **Worktree вместо веток в основном checkout.** Хуки запрещают запись в `main`; работа идёт в worktree. Для среза, затрагивающего несколько репозиториев, worktree собираются в зеркальный workspace `.wt/<срез>/` рядом с основным: - `docs/`, `arm-core/`, `tabular-parser/`, `scan-gateway/` — worktree веток `feat/-`, а `fixtures/`, + `docs/`, `arm-core/`, `tabular-parser/`, `scan-gateway/`, `arm-statements/` — worktree веток `feat/-`, а `fixtures/`, `reference-old-arm/`, `CLAUDE.md`, `.claude/` — симлинки на основной workspace. Так относительные ссылки `../docs`, `../fixtures` и хуки работают и в ветке. `.wt/` в git не попадает (workspace — не репозиторий). @@ -67,7 +68,7 @@ git remote add origin && git push -u origin main | Слой | Где | Статус | Правило для агента | |---|---|---|---| -| Модель данных | `docs/model/data-model-v5.18.dbml` | стабильный, версионируется | единственный источник имён и структур | +| Модель данных | `docs/model/data-model-v5.19.dbml` | стабильный, версионируется | единственный источник имён и структур | | Правила домена | `docs/context/05-domain-rules.md` | курируемый — пишет человек/агент по итогам ревью | **единственный вход алгоритмов в код**; из ТЗ напрямую не кодируем | | Открытые вопросы | `docs/context/07-open-questions.md` | живой реестр | каждое допущение в коде — `ASSUMPTION(OQ-nn)` | | Экспорт ТЗ | `docs/source/tz/` | снимок на дату из `docs/source/tz/_export.md` | читать для контекста; расхождение с §05 → вопрос, не код | diff --git a/context/03-architecture.md b/context/03-architecture.md index 86d6f8c..b3e1c34 100644 --- a/context/03-architecture.md +++ b/context/03-architecture.md @@ -1,4 +1,4 @@ -# 03. Архитектура (целевая, по решениям DECISION-1…5 и ARCH-* в DBML v5.18) +# 03. Архитектура (целевая, по решениям DECISION-1…5 и ARCH-* в DBML v5.19) ## Компоненты (= репозитории, см. `00-workspace-layout.md`) @@ -31,8 +31,9 @@ лимиты, чтение, дедлайн, вид каждого листа): листы ОСВ идут в конвейер `osv.py`, листы форм — в конвейер форм `statements/` (блоки форм, колонки, шапка, строки, соотношения §7, склейка продолжений); документы результата — сначала ОСВ, затем формы. Контракт результата — - `docs/contracts/recognition-result.schema.json` (контракт v1.2: `DISCLOSURE` в `document_type`, `statementDocument.checks`, - границы периода ОСВ `period_start/period_end/is_from_year_start`). Правила распознавания + `docs/contracts/recognition-result.schema.json` (контракт v2.0 — статусы строк, `needs_review`, `document.status` и + структурные предупреждения, change `unified-statement-normalization`; до перехода сервис пишет v1.2: `DISCLOSURE` в + `document_type`, `statementDocument.checks`, границы периода ОСВ `period_start/period_end/is_from_year_start`). Правила распознавания заголовков/группировок — `rules/*.yaml`. - **scan-gateway** — адаптер ИС Сканирование: положить файл в горячую папку `Input/.` (расширение — суффикс `original_name` в нижнем регистре; файл пишется сразу в `Input` с перезаписью существующего, без временного @@ -163,6 +164,20 @@ advisory-lock по `(ЮЛ, sha256)` (двойной клик не проскак - **Нагрузка и транзакции.** Переход версии после загрузки выполняется в отдельной короткой транзакции (`session_transaction`), сбой не отменяет принятые файлы (R-10). Чтение (`GET …/osv`, сводка, экспорт) блокировок не берёт. +### Общий код нормализации ББ/ОФР — `arm-statements` (change `unified-statement-normalization`, целевое) + +Шаги 2–8 нормализации форм (раскладка, статусы строк, значения, код и наименование, сборка документа, проверки §7, +`needs_review`, статус и предупреждения) и их словари живут в отдельном репозитории `arm-statements` пакетом +`arm_statements/` (Р-1: платформа не собирает Python-пакеты). В tabular-parser, scan-gateway и arm-core пакет лежит +**копией** (`api/arm_statements/`, маркер версии — `api/arm_statements/VERSION`) с тестами, импортируется по имени +`arm_statements`; копию раскладывает и проверяет `sync-statements` в docs (`--check`: 0 — синхронно, 10 — отстала, 20 — +прямая правка, 2 — нет checkout; вызывается из `make check-full`, не из `make check`; механика — `dev/checks.md`). +Упорядоченные перечни кодов форм — данные `contracts/statement-code-orders.yaml`, в пакете — их байтовая копия. Сервисы держат только адаптер +шага 1 (json ИС / лист Excel → `FormCandidate`); ядро считает тем же кодом после правок аналитика и не держит своих +копий словаря §7. Словарь «код → наименование» — только в ядре. Правила — `05-domain-rules.md` §11; маркер версии +копии — `service.statements_version` результата → `recognition_job.statements_version`. Порядок выкладки — «сначала +ядро» (ядро принимает 1.x и 2.0). Пункты ниже описывают поведение до перехода (Срез 3b). + ### Формы ББ/ОФР, проверки §7 и API экрана проверки (Срез 3b) Формы (`statementDocument` контракта v1.2) приходят от обоих сервисов распознавания в одном формате; ядро — единственное @@ -236,7 +251,7 @@ api/src/ # машины статусов, policy — pydantic-модели на входе и выходе services/ # use-cases: оркестрация domain + repositories + outbox + audit_event repositories/ # SQLAlchemy-запросы, в т. ч. запросы-вычисления (сводная ОСВ, шахматка) - models/ # ORM 1:1 с DBML v5.18; имена таблиц/колонок/enum — как в DBML + models/ # ORM 1:1 с DBML v5.19; имена таблиц/колонок/enum — как в DBML schemas/ # pydantic DTO API /api/v1 core/ # config, clients (S3; NATS outbox-publisher и result-consumer), auth (JWT РАИСА / dev-эмитент), # logger, exception_handlers @@ -326,7 +341,7 @@ FastAPI» платформы РАИСА/aurora): дерево импортиро `version_table_schema` и `include_name` Alembic). Следуем шаблону, не `public`. - Alembic берёт URL из настроек, не из `alembic.ini`; ревизии — `api/alembic/versions/`, имя файла стандартное `_.py`. Автогенерация — только черновик: итоговую ревизию сверяет с DBML `api/scripts/check_dbml_vs_orm.py`. -- PK везде `uuid` через `UUIDPrimaryMixin`; имена таблиц, колонок и enum'ов — ровно как в DBML v5.18. +- PK везде `uuid` через `UUIDPrimaryMixin`; имена таблиц, колонок и enum'ов — ровно как в DBML v5.19. ### Инфраструктурные клиенты diff --git a/context/04-data-model-guide.md b/context/04-data-model-guide.md index a6ca68b..8cf2715 100644 --- a/context/04-data-model-guide.md +++ b/context/04-data-model-guide.md @@ -1,8 +1,8 @@ -# 04. Как читать модель данных v5.18 +# 04. Как читать модель данных v5.19 -Файлы: `docs/model/data-model-v5.18.dbml` (источник истины), `docs/model/entities-v5.18.md` (смысл таблиц; предыдущие -версии `data-model-v5.13…v5.17.dbml` и `entities-v5.13…v5.17.md` остаются как история), -`docs/model/diagrams/part01…09.{svg,png}` (диаграммы v5.18 по частям из таблицы ниже; перерисовываются из DBML командой +Файлы: `docs/model/data-model-v5.19.dbml` (источник истины), `docs/model/entities-v5.19.md` (смысл таблиц; предыдущие +версии `data-model-v5.13…v5.18.dbml` и `entities-v5.13…v5.18.md` остаются как история), +`docs/model/diagrams/part01…09.{svg,png}` (диаграммы v5.19 по частям из таблицы ниже; перерисовываются из DBML командой `make diagrams` в корне репозитория docs — скрипт `scripts/gen_model_diagrams.py`, нужен npx; при новой версии DBML обновить `DBML` в Makefile, а новую таблицу — добавить в раскладку частей скрипта и в эту таблицу). @@ -15,11 +15,17 @@ | 03 Бухгалтерские справочники | `chart_of_account`, `elimination_rule`, `elimination_rule_account`, `required_document_rule` | план счетов (seed из 1С), правила элиминации Прил. 3/4 (версионируются), чек-лист по подходу | | 04 Файлы и распознавание | `s3_object`, `stored_file`, `document`, `recognition_job` | физический файл → задание → логические документы | | 05 Разобранные ОСВ | `osv_entry` | строка ОСВ с шестью суммами, сырым контрагентом и резолвом | -| 06 Формы ББ/ОФР | `financial_statement_meta`, `_column`, `_line`, `_value` | шапка, колонки-даты, строки с кодами, значения с confidence | +| 06 Формы ББ/ОФР | `financial_statement_meta`, `_column`, `_line`, `_value`, `document_issue` | шапка, колонки-даты, строки со статусами, значения с confidence и `needs_review`, предупреждения и нарушения §7 | | 07 Консолидация | `consolidation`, `consolidation_version`, `_status_history`, `_lock`, `_member`, `_document` | логическая консолидация (ГК × период) и её версии с периметром | | 08 Корректировки и КФО | `correction`, `goodwill`, `kfo_result`, `kfo_result_line`, `kfo_result_line_company`, `client_kfo_line` | ручные решения и результат | | 09 Комментарии, аудит, ЕХ | `comment`, `audit_event`, `ex_snapshot`, `ex_snapshot_member`, `ex_snapshot_item` | полиморфные комментарии, append-only журнал, замороженные срезы | +**v5.19:** формы ББ/ОФР по контракту v2.0 (change `unified-statement-normalization`, `05-domain-rules.md` §11): строки +формы хранятся все — с кодом (повтор кода законен, уникальность `(meta_id, line_code)` снята) и без (`line_status` +`unreadable` / `missing` / `detail`); `needs_review` значения и строки **хранится** (пересматривает v5.13); предупреждения и +нарушения §7 — `document_issue`; две формы одного типа в файле — разные `document` (`file_ordinal`); чтения «по коду» — +агрегат формы по коду (сумма строк `ok`, не хранится). Плюс `related_group.crm_id`. + **v5.18:** `stored_file.consolidation_id` — в какую консолидацию (период ГК) загружен файл; файлы, загруженные в консолидацию, — основа рабочего стола консоли, общего для её версий (полный состав стола, с файлами документов, привязанных к версиям, — `05-domain-rules.md` §9.5 п. 11). Что использует версия — `consolidation_document`. ## Соглашения @@ -60,9 +66,9 @@ ## v4 → v5.13: таблица переименований (обязательна при чтении ТЗ) v5.14 имена таблиц и полей не менял (добавлены `related_group_member.kind` и два значения `audit_action`), v5.15 — тоже -(добавлено значение `osv_data_type.NU`), v5.16 — тоже (колонка `osv_entry.counterparty_match_kind`), v5.17 — тоже (колонка `legal_entity.kpp`), v5.18 — тоже (колонка `stored_file.consolidation_id`), поэтому таблица справедлива и для v5.14…v5.18. +(добавлено значение `osv_data_type.NU`), v5.16 — тоже (колонка `osv_entry.counterparty_match_kind`), v5.17 — тоже (колонка `legal_entity.kpp`), v5.18 — тоже (колонка `stored_file.consolidation_id`), v5.19 — тоже (колонки форм, `document_issue`, `related_group.crm_id`), поэтому таблица справедлива и для v5.14…v5.19. -| В страницах ТЗ (модель v4 и старше) | В v5.13 / v5.14 / v5.15 / v5.16 / v5.17 / v5.18 | +| В страницах ТЗ (модель v4 и старше) | В v5.13 / v5.14 / v5.15 / v5.16 / v5.17 / v5.18 / v5.19 | |---|---| | `stored_file.period_year / period_quarter / document_type / osv_account` | `document.period_year / period_quarter / document_type / osv_account_code / is_cumulative` | | `stored_file.status` «Загружен / Обработка / Требуется проверка / Ошибка» | техника: `stored_file.status` UPLOADED/PROCESSING/PROCESSED/ERROR/SUPERSEDED; проверка: `document.status` RECOGNIZED/NEEDS_CHECK/CONFIRMED/ERROR | diff --git a/context/05-domain-rules.md b/context/05-domain-rules.md index 5dc4098..b6f7f0f 100644 --- a/context/05-domain-rules.md +++ b/context/05-domain-rules.md @@ -1,4 +1,4 @@ -# 05. Правила предметной области (выжимка из ТЗ, имена — v5.18) +# 05. Правила предметной области (выжимка из ТЗ, имена — v5.19) Каждый раздел: источник в ТЗ → правило → где в модели. Где ТЗ молчит или противоречит — пометка **[открыто]** и ссылка на `07-open-questions.md`. @@ -305,13 +305,18 @@ result-osv-needs-check.json`. Формат определяется по сигнатуре байтов (zip → XLSX, либо XLSB при наличии `xl/workbook.bin`; OLE2 → XLS), а не по `file_type` запроса; несовпадение — предупреждение. Листы без признаков ОСВ и форм пропускаются с -предупреждением (`sheet=<имя> skipped: нет признаков ОСВ или формы`; листы форм теперь разбираются, §12). Другие предупреждения: +предупреждением (`sheet=<имя> skipped: нет признаков ОСВ или формы`; листы форм теперь разбираются, §11.2). Другие предупреждения: `sheet=<имя> hierarchy=<стратегия>` (всегда), свёрнутое сальдо, нераспознанное число (с номером строки), не найденное поле шапки, «нет строк данных». xlsb не содержит outline level — каскад начинается с отступа (предупреждение «xlsb: outline level недоступен»). xls (BIFF5/7) без записи CODEPAGE читается как cp1251 (кодовая страница 1С и Excel в русской локали; по содержимому кодировка не угадывается) с предупреждением «xls без кодовой страницы — строки прочитаны как cp1251»; без этого xlrd декодирует строки как latin-1 и лист ОСВ не находится. BIFF8 хранит строки в Unicode и от CODEPAGE не зависит. **Реализовано**, tabular-parser #47, AURORA-1662. +**С контракта v2.0** (change `unified-statement-normalization`, §11.9) у каждого `osvDocument` есть `status` (`done`, когда +контрольные суммы §1.4 листа сошлись, иначе `needs_check` — те же условия, что выше, но по листу) и `warnings[]` — +перечисленные предупреждения листа структурами `{code: "OSV_…", severity: "info", message}`; строки `sheet=<имя> …` в +`metrics.warnings` больше не пишутся (предупреждения уровня книги — `metrics.warnings` структурами, например +`FILE_TYPE_MISMATCH`). Разбор ОСВ не меняется. Лимиты: `PARSE_MAX_BYTES` (по умолчанию 50 МиБ) и `PARSE_TIMEOUT_SECONDS` (120 с; кооперативный — проверка между листами, в циклах по листу и между стратегиями; чтение книги библиотекой он не прерывает). Поэтому до библиотеки чтения действуют @@ -521,51 +526,96 @@ variance = |amount_a − amount_b|; variance_pct = variance / min(amount_a, amou Межформенная: `1370(отчётная дата) − 1370(начало года) = 2400(ОФР за период)` — при расхождении КА может акцептовать с комментарием (`financial_statement_meta.checks_accepted_*`). -**Парсер tabular-parser (§12 п. 7)** считает **жёсткими** (нарушение → `needs_check`) соотношения ББ — `1100`, `1200` -(+1215), `1300`, `1400`, `1500`, `1600 = 1100 + 1200`, `1700 = 1300 + 1400 + 1500`, `1600 = 1700` — и ОФР — `2100`, `2200`, -`2300`; **мягкими** (предупреждение) — `2400 = 2300 − 2410 + 2420` и варианты ФСБУ 4/2023 (`2400 = 2300 + 2410 + 2460`, -`2410 = 2411 + 2412`), `2500 = 2400 + 2510 + 2520 + 2530` и «сумма пятизначных подстрок = родитель» -(`ASSUMPTION(OQ-12)`), последняя — по колонке и только если хотя бы одна подстрока родителя в ней непуста (`amount` не `null`; ноль — значение): без детализации соотношение не проверяется, ни нарушения, ни предупреждения (AURORA-1527, решение владельца 16.10.2026). Допуск ±1 в единицах формы (расхождение до 0,001 % наибольшего числа соотношения — округление, -мягкое, OQ-138); соотношения считаются по отображаемым значениям (п. 5 §12), расходы — двойной расчёт знаков (`abs_terms`). Межформенная проверка -`1370 − 1370(начало года) = 2400` парсером не выполняется. Это сигнал о структуре и данных файла, а не замена проверок -ядра: ядро (Срез 3) пересчитывает §7 и ведёт акцепт (`financial_statement_meta.checks_accepted_*`). +**Самопроверка в сервисах (общий код, §11.8; change `unified-statement-normalization`, контракт v2.0).** Оба сервиса +(tabular-parser и scan-gateway) считают §7 одним кодом `arm_statements` по одному словарю `statement_checks.yaml` и кладут +результат в `documents[].checks` (`relations_failed`, `soft_relations_failed`; у нарушения — `column_index`, текст +соотношения, `codes` — коды цели и членов, `expected`, `actual`). Ядро хранит `checks` как пришли (`document_issue`) и +пересчитывает тем же кодом только после правок аналитика; своей копии словаря у ядра нет. Правила счёта: -**Ядро (Срез 3b, реализовано).** Ядро считает §7 само, для обоих источников (Excel и OCR), по **текущим** суммам формы -(`financial_statement_value.amount` после правок аналитика); результат не хранится, считается на лету (`GET -/documents/{id}/statement`, подтверждение). Правила — словарь `arm-core/api/rules/statement_checks.yaml`: **копия** -словаря tabular-parser (те же жёсткие и мягкие соотношения, допуск ±1 в единицах формы, округление 0,001 %, `abs_terms`, -мягкая «сумма пятизначных подстрок = родитель», по колонке — только если хотя бы одна подстрока родителя в ней непуста: `amount` не `null`, ноль — значение; пустая детализация не проверяется, AURORA-1527); тест идентичности копий между репозиториями не ставится, расхождение — -известный риск (`03-architecture.md`). Соотношение считается по каждой колонке и только если строка-цель (левая часть) есть в форме; -отсутствующий код и пустая сумма — 0 (ненулевые слагаемые при пустой цели дают жёсткое нарушение). Результат в ответе API: `failed` (жёсткие), `soft_failed` (мягкие, в -том числе «в пределах округления»), `cross_form`, `accepted`. +- **Жёсткие** (нарушение → `needs_check`): ББ — `1100`, `1200` (+1215), `1300`, `1400`, `1500`, `1600 = 1100 + 1200`, + `1700 = 1300 + 1400 + 1500`, `1600 = 1700`; ОФР — `2100`, `2200`, `2300`. **Мягкие** (`info`, `ASSUMPTION(OQ-12)`): `2400 = + 2300 − 2410 + 2420` и варианты ФСБУ 4/2023 (`2400 = 2300 + 2410 + 2460`, `2410 = 2411 + 2412`), `2500 = 2400 + 2510 + + 2520 + 2530`, «сумма пятизначных подстрок = родитель» (по колонке — только если хотя бы одна подстрока родителя в ней + непуста: `amount` не `null`, ноль — значение; AURORA-1527). Мягкое соотношение на статус и подсветку не влияет, но + **сошедшееся точно** (расхождение в пределах допуска ±1, без «округления») подтверждает неуверенные значения своих + кодов (Р-21, §11.9): так покрываются 2400, 2410, 2411, 2412, 2460, 25xx и пятизначные подстроки. +- **Значение кода** в колонке — сумма по всем строкам `ok` с этим кодом (повтор кода — законный случай, Р-4); строки + `detail`, `unreadable`, `missing` в соотношения не входят; нарушение относится ко всем строкам его кодов. + `duplicate_line_codes` не выдаётся. +- **Посчитанное соотношение.** Соотношение считается, если непуста цель или хотя бы один член (есть строка `ok` с кодом + и непустой суммой); пусто всё — пропускается. Пустая цель при непустых членах — нарушение («БАЛАНС» без кода ушёл в + `detail` — 1600 пуст, `1600 = 1100 + 1200` подсвечивает родителя); непустая цель при пустых членах — тоже нарушение + (лист только с активом: `1600 = 1700` при отсутствующем 1700 — `needs_check`). До v2.0 соотношение с отсутствующей + целью пропускалось у обоих сервисов и в ядре — поведение изменено; посчитанное соотношение из одних `null` ничего не + подтверждает. +- **Допуск и округление:** жёсткое соотношение не сошлось, если расхождение больше ±1 в единицах формы **и** больше + 0,001 % наибольшего по модулю числа соотношения — только тогда оно в `relations_failed`; расхождение в пределах + округления (OQ-138) и мягкие соотношения — в `soft_relations_failed`, это `info`, на статус и подсветку не влияют. + Соотношения считаются по отображаемым значениям (§11.2, числовой формат Excel). +- **Члены по модулю (`abs_terms`: 2120, 2210, 2220, 2330, 2350, 2410).** Правая часть считается дважды — как записано и + с этими членами по модулю (1С выгружает расходы со знаком минус, 66н — в скобках). Такое соотношение сходится при + любом знаке члена и потому **подтверждает величину, но не знак**: знак сомнительной ячейки подтверждается конвенцией + колонки (§11.9). +- **Упрощённый набор.** Набор выбирается по `form_variant` документа (§11.7): у упрощённой формы нет подытогов 1100 / + 1200 (ББ) и 2100 / 2200 / 2300 (ОФР), свои равенства по бланку упрощённой формы (приказ 66н, прил. 5; + `ASSUMPTION` до первого образца в fixtures): ББ — `1600 = 1150 + 1170 + 1210 + 1230 + 1250` (жёсткое), `1700 = 1300 + + 1350 + 1360 + 1410 + 1450 + 1510 + 1520 + 1550` (жёсткое), `1600 = 1700` (жёсткое); ОФР — `2400 = 2110 − 2120 − 2330 + + 2340 − 2350 − 2410` (**мягкое**, как `2400` полного набора: знак 2410 не определён, OQ-12; `abs_terms` те же; решение + владельца 18.10.2026, Р-22 / S-5). Оба набора — в одном словаре `statement_checks.yaml`. Без упрощённого набора полный + дал бы нарушения «пустой цели» на каждой упрощённой форме. `form_variant = null` (документ, оставшийся в БД с времён до + v5.19, если база не очищена) — полный набор; у результата 1.x вариант определяется по структуре кодов при приёме. +- **Родитель пятизначной подстроки** — ближайшая четырёхзначная строка выше с теми же первыми тремя цифрами, иначе код + из первых четырёх цифр (12310 под 1230); то же правило ядро отдаёт экрану как `parent_code`. +- **Межформенная** `1370(отчётная дата) − 1370(начало года) = 2400` сервисами не считается — только ядром (ниже). + +**Ядро.** При загрузке результата v2.0 ядро §7 **не пересчитывает**: `checks`, `needs_review` и статус документа +берутся из результата (§9.3, §9.5 п. 2). После правки аналитика (`PATCH …/statement`) ядро пересчитывает §7 копией +`arm_statements` по текущим суммам (`financial_statement_value.amount` после правок) — с теми же правилами, что в +сервисах. Пересчёт идёт точкой входа пакета для пересчёта по сохранённым данным (спека `statement-normalization`): +она же заново определяет вариант формы и редакцию документа для проверок ядра. Результат 1.x (окно выкладки) `checks` +не несёт: ядро **один раз при приёме** считает §7 той же точкой входа (вариант — по структуре кодов) и пишет нарушения в +`document_issue` (`source = core`, `relation` / `soft`); условие D-6 «проверки сошлись» для 1.x берётся из этого +расчёта. Формы, существовавшие до миграции v5.19, не пересчитываются: перед выкладкой база очищается от форм ББ/ОФР +(Р-24, §9.5 п. 2), а у формы, оставшейся в неочищенной базе, записей `document_issue` нет — до первой правки её +нарушения не показываются и подтверждение их не проверяет (корректность не гарантируется). Экран (`GET …/statement`) и подтверждение (`CHECKS_NOT_PASSED`) читают нарушения **только** из +`document_issue` — у любой формы, какой бы версией контракта она ни пришла. Межформенная `1370(отчётная дата) − 1370(начало +года) = 2400` — **мягкая**, считается ядром по агрегату формы по коду (§9.5 п. 2) и не блокирует подтверждение: пара — +другой документ формы того же ЮЛ с тем же годом и кварталом периода (файл не `SUPERSEDED`; подтверждённый +предпочтительнее, далее самый поздний `created_at`); считается, только если найдены пара и обе колонки. Результат в +ответе API: `failed` (жёсткие), `soft_failed` (мягкие, в том числе «в пределах округления»), `cross_form`, `accepted`; +у нарушения — `codes` и `line_ids`. Показ пятизначных подстрок (AURORA-1527, решение владельца 16.10.2026): ядро отдаёт у каждой строки формы вычисляемое -`parent_code` — код четырёхзначного родителя подстроки по тому же правилу, что и проверка «сумма подстрок = родитель» -(п. 7 §12; одна функция в ядре, порядок кодов тот же); у остальных строк `null`. Экран показывает подстроки сразу под -родителем, пустые подстроки (нет значения ни в одной колонке) свёрнуты за переключателем; правила родителя на фронте нет. -Только показ: парсеры и данные не меняются. -Межформенная `1370(отчётная дата) − 1370(начало года) = 2400` — **мягкая** и не блокирует подтверждение: пара — другой -документ формы того же ЮЛ с тем же годом и кварталом периода (файл не `SUPERSEDED`; подтверждённый предпочтительнее, далее -самый поздний `created_at`); считается, только если найдены пара и обе колонки (ОФР часто приходит отдельным файлом позже). +`parent_code` (правило выше); экран показывает подстроки под родителем, пустые подстроки свёрнуты за переключателем. **Акцепт** — по жёстким нарушениям, с комментарием (1–2000 символов), `POST /documents/{id}/statement/checks/accept`; -без нарушений — 409 `NOTHING_TO_ACCEPT`. Акцепт **сбрасывается любой правкой формы** (значения или шапки): данные -изменились, проверку нужно смотреть заново. Аудит акцепта — `RECOGNIZED_DATA_EDIT` (отдельного `audit_action` нет, новое -значение enum потребовало бы DBML v5.17). Формулы одни для редакций `66n` и `fsbu4` (`ASSUMPTION(OQ-12, OQ-146)`); редакцию -ядро определяет по дате периода документа и отдаёт в ответе (`edition`: `66n` при дате ≤ 2024-12-31, `fsbu4` — позже, `null` -без даты; решение владельца 07.10.2026 по OQ-145 — редакция в контракт не передаётся, досрочный ФСБУ 4/2023 — новый OQ при -первом случае); раздельные словари и формулы по редакциям — вместе с составом `fsbu4` (OQ-146, OQ-150). +без нарушений — 409 `NOTHING_TO_ACCEPT`. Акцепт **сбрасывается любой правкой формы** (с v2.0 — правкой данных: +подтверждение ячейки или кода без изменения его не сбрасывает, §9.5 п. 9). Аудит акцепта — +`RECOGNIZED_DATA_EDIT`. Формулы одни для редакций `66n` и `fsbu4` (`ASSUMPTION(OQ-12, OQ-146)`); редакцию ядро определяет +по дате периода документа и отдаёт в ответе (`edition`: `66n` при дате ≤ 2024-12-31, `fsbu4` — позже, `null` без даты; +OQ-145); тем же правилом сервисы разрешают ничью между редакциями упорядоченного перечня кодов (§11.6, §11.9). **Полный состав формы (arm-core#80, решение владельца 09.10.2026).** Экран проверки показывает все строки формы редакции документа (`edition`): справочник `arm-core/api/rules/statement_lines.yaml` (66н и ФСБУ 4/2023, ББ и ОФР, из шаблонов банка `fixtures/statements-xlsx/templates/`). Строки и ячейки, которых нет в источнике, ядро достраивает в -ответе (`id = null`, `origin = template`) и в БД не пишет; первая непустая правка создаёт строку -(`line_code_original_value = NULL` — признак «не из источника») и ячейку как правку аналитика (аудит -`RECOGNIZED_DATA_EDIT`). Пустая добавленная строка не влияет на статус, проверки §7 и автоподтверждение; созданная +ответе (`id = null`, `origin = template`) и в БД не пишет; первая непустая правка создаёт строку (признак «не из +источника» — до v5.19 `line_code_original_value = NULL`, с v5.19 — `row_index = NULL`) и ячейку как правку аналитика +(аудит `RECOGNIZED_DATA_EDIT`). Пустая добавленная строка не влияет на статус, проверки §7 и автоподтверждение; созданная правкой строка участвует в проверках, пока у неё есть хоть одна сумма. Без редакции (нет даты периода) строки не -достраиваются. Строки «БАЛАНС» (1600, 1700) на экране выделяются цветом. +достраиваются. Строки «БАЛАНС» (1600, 1700) на экране выделяются цветом. **С v2.0** (change +`unified-statement-normalization`): справочник ядра — только словарь имён (код → наименование по редакциям; у кода может +быть несколько имён — синонимы и законные повторы кода в бланке, Р-20: 1220 «Налог на добавленную стоимость по +приобретенным ценностям» и «НДС, начисленный по отгрузке», 1520 «Кредиторская задолженность» и «Обязательство по +аренде»; загрузчик повтор кода не запрещает, сверка наименования — с любым именем кода, шаблонная строка берёт первое); +порядок и состав редакций ядро берёт из копии упорядоченного перечня кодов `arm_statements` (данные — +`contracts/statement-code-orders.yaml`; `sync-statements --check` сверяет, что все коды словаря имён есть в перечне); код, у которого есть хотя бы одна строка `ok` из файла, шаблоном не +достраивается; строки `unreadable` / `missing` шаблонной строкой молча не заменяются — показываются отдельно со статусом. +Наименование строки из источника хранится как распознано; экран показывает справочное имя по коду, файловое — рядом +(Р-3, AURORA-1406 / 1522). -Когда: после распознавания; после «Сохранить» правок; при «Подтвердить данные». Непройдено → `document.status = -NEEDS_CHECK`; пройдено/акцептовано + нажато «Подтвердить» → `CONFIRMED` + `audit_event(DOCUMENT_CONFIRM)`. -Отрицательные значения в формах — в скобках `(2100)` → `-2100`. Значения с `confidence < 100` подсвечиваются. +Когда: в сервисе при распознавании; в ядре после «Сохранить» правок. Непройдено → `document.status = NEEDS_CHECK`; +пройдено/акцептовано + нажато «Подтвердить» → `CONFIRMED` + `audit_event(DOCUMENT_CONFIRM)`. Отрицательные значения в +формах — в скобках `(2100)` → `-2100`. Подсветка ячеек — только по `needs_review` (§11.9): уверенность, `is_recognized` и +нарушения §7 сведены в него сервисом; «подтверждение проверкой» — сошедшееся посчитанное жёсткое соотношение снимает +подсветку с сомнительной ячейки (Р-7). --- @@ -665,6 +715,29 @@ INITIATED; в Срезе 2c консолидаций ещё нет, поэтом (`financial_statement_meta` нет), подтверждается без условий п. 9.5.4, а `GET`/`PATCH …/statement` по нему — 409 `DOCUMENT_NOT_STATEMENT`. +**Документ формы при приёме контракта v2.0** (change `unified-statement-normalization`, Р-19; правило выше остаётся для +результатов 1.x на окне выкладки). `RECOGNIZED` — только если одновременно: `document.status = done` из результата и он согласован с +содержимым документа (нет строк `unreadable` / `missing` с суммой или текстом в значениях, нет строк, значений и полей +шапки с `needs_review`, нет `checks.relations_failed` и предупреждений документа уровня `error`; иначе ядро считает +документ `needs_check` и пишет `RESULT_STATUS_INCONSISTENT` `info` — страховка от ошибки сервиса; проверка — вызов +общей функции статуса §11.9 по входу, собранному из результата, а не пересчёт §7: значения строк `detail` в неё не +входят); в `metrics.warnings` +файла нет предупреждений уровня `error` (потерянный json, таблица ИС или исключение разбора таблицы — решение № 28 среза +3a; `needs_check` соседнего документа и статус конверта не учитываются); `counterparty_check ≠ MISMATCH`; определены +`period_year` и `period_quarter` (дата — конец квартала); у каждой строки `ok` с неуверенным кодом (`line_code_confidence +< 100`) распознанное наименование совпало со справочным по коду (Р-16, правило совпадения — §11.9); в файле нет второй +формы того же типа и периода (Р-18; «того же периода» — та же пара `period_year`, `period_quarter`, формы без квартала не +считаются). Иначе `NEEDS_CHECK`. Проверки §7, `needs_review` и предупреждения сервиса при загрузке не пересчитываются. +После правки или акцепта статус пересчитывается общим кодом (§11.9) с подтверждениями аналитика и теми же условиями ядра; +`RECOGNIZED` после правки — только вручную, как и прежде. Документ, принятый по 1.x, после правки пересчитывается так же +(общим кодом), его `LEGACY_WARNING` и `MAPPING_WARNING` — `info` и статус не держат. + +**Документ ОСВ при приёме контракта v2.0.** `RECOGNIZED` — только если `osvDocument.status = done` (контрольные суммы +§1.4 листа сошлись), `checks` листа без нарушений, в `metrics.warnings` файла нет предупреждений уровня `error`, +`counterparty_check ≠ MISMATCH` и нет предупреждений маппинга ядра по этому листу; статус конверта не учитывается (как +Р-19 у форм: ОСВ в книге с сомнительной ОФР автоподтверждается). Предупреждения листа (`osvDocument.warnings`, `OSV_*`, +`info`) хранятся в `document_issue` документа ОСВ и отдаются в `DocumentOut.issues`. + ### 9.4 Задание (`recognition_status`): queued → published → processing → done | needs_check | error | timeout → dlq; replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; `reply_deadline` OCR 20 мин (решение владельца 09.10.2026; note DBML «5 мин» — OQ-159), парсер 5 мин. @@ -702,6 +775,50 @@ replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; ` `NEEDS_CHECK`. Файл только с формами — не ошибка; `ERROR` по результату — только при пустом `documents`. Страницы одной формы из разных файлов не склеиваются (OQ-140), строки без кода не хранятся (OQ-36, OQ-141, OQ-149). Статус документа формы — §9.3. + **Формы по контракту v2.0** (change `unified-statement-normalization`; правила выше — для результатов 1.x на окне + выкладки, у них поля v2.0 заполняются по умолчанию: `line_status = ok`, `row_index` по порядку строк результата с 0, + `line_code_original_value = line_code`, `source_ref = null`, `text = string_value`, `needs_review` значений = **НЕ** + «правлено или уверенность 100 и `is_recognized`» (чистое значение — `false`), `form_variant` — по структуре кодов, + `file_ordinal` — индекс в `documents[]`, `header_source` — из полей шапки 1.x; нарушения §7 ядро считает один раз + точкой входа `arm_statements` и пишет в + `document_issue` (`source = core`, §7 «Ядро»); предупреждения маппинга 1.x по документу (повтор кода, значение вне + колонок) — `document_issue` `MAPPING_WARNING` `info` (`source = core`; статус при приёме — по D-6, после правки они + статус не держат); строки `metrics.warnings` 1.x — в `recognition_job.metrics.warnings` структурами `{code: + "LEGACY_WARNING", severity: "info", message}` уровня файла; `statements_version` задания — `null`, и + `STATEMENTS_VERSION_MISMATCH` у такого документа не пишется). + Результат пишется как пришёл, без повторной дедупликации: каждая строка — `financial_statement_line` с `row_index` + (у строки из результата всегда не `null`: `row_index IS NULL` — признак строки, созданной правкой), + `source_ref` (`ref`, уникален в форме), `line_status`, `line_code` (`null` у `unreadable` / `missing` / `detail`), + `line_code_original_value` (= `line_code` результата; правками не меняется), `line_code_text`, `parent_row_index` + (родитель `detail`), `needs_review`; повтор кода — законный случай, хранятся все + строки (Р-4; строки без кода хранятся — Р-8, отменяет OQ-36 / OQ-141). Значения — с `source_text` (`values[].text`) и + `needs_review`; колонки — с `period_kind` и `label_confidence`; шапка — с `form_variant`, `needs_review_fields` и + `header_source` (`<поле>_text` и `<поле>_confidence` всех полей; `*_original_value` — как прежде: `inn`, + `report_date_text`, `units_text`; `inn_confidence` результата, `organization_name_original_value` = + `organization_name`); `checks` + и предупреждения документа — в `document_issue` (источник `service`; нарушение — `kind = relation`, `code = + RELATION_FAILED`, `error` или `kind = soft`, `code = RELATION_SOFT`, `info`, с `codes`, `relation`, `expected`, + `actual`, `column_id`; предупреждение о столбце вне колонок формы — с `column_index`) вместе с проверками ядра уровня + `info` (`CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT`); версия общего кода — + `recognition_job.statements_version`. **Каждый** документ результата (ОСВ, формы, `DISCLOSURE`, `OTHER`) при любом + MAJOR получает `file_ordinal` — индекс в `documents[]` (несозданный документ номер не занимает и не сдвигает + остальные). Предупреждения файла хранятся в `recognition_job.metrics.warnings` структурами (у 1.x — `LEGACY_WARNING`) + и отдаются в `JobBrief.warnings` объектами (строки старых заданий конвертируются при чтении). Каждый `BALANCE` / + `INCOME` становится отдельным документом: + вторая форма того же типа в файле **хранится** (Р-10, Р-15; ни одна из двух форм одного типа и периода не + автоподтверждается — Р-18); ОСВ по счёту, `DISCLOSURE` и `OTHER` — по-прежнему один документ на тип (и счёт) на файл. + Все чтения формы «по коду» (межформенная проверка, суммы формы для консолидации и выгрузок, `parent_code`, раскладка + экрана, правка по `line_code`, метрики `fields_*`) идут через одну чистую функцию **«агрегат формы по коду»**: сумма + `amount` строк `ok` с кодом по колонке, список `line_id`, справочное имя (DECISION-5, не хранится). Строки `detail`, + `unreadable`, `missing` в агрегат, КФО и снимок ЕХ не попадают — цена Р-8. Записи `document_issue` удаляются вместе + с документом при перезагрузке файла. + **Перед выкладкой ядра v5.19** база очищается от форм ББ/ОФР (Р-24; скрипт ядра `scripts/purge_statements.py`, + `--dry-run` с отчётом, затем `--apply`; владелец или платформенная команда по его заявке, после резервной копии): + документы `BALANCE` / `INCOME` с `financial_statement_*`, комментариями и привязками к версиям «в работе»; файлы без + ОСВ — целиком с заданиями (объектам S3 — `expires_at`); смешанные «ОСВ + формы» — только формы, или целиком по + решению владельца; формы в верифицированных версиях — отказ без изменений; ОСВ, консолидации и справочники не + трогаются; системный аудит `FILE_DELETE`. Миграция v5.19 только добавляет колонки и значения по умолчанию (спека + `arm-core`, «Миграция DBML v5.19», «Очистка форм перед выкладкой»). 3. **Автозамена.** После успешного маппинга другие файлы того же ЮЛ (не `SUPERSEDED`), содержащие документ с теми же `document_type`, `osv_account_code`, `period_year`, `period_quarter`, переводятся в `SUPERSEDED` (`superseded_by_file_id` = новый файл); `version_no` нового = максимум заменённых + 1. Новый файл без контекста (`consolidation_id IS NULL`) наследует `consolidation_id` первого (по `id`) заменённого файла, у которого он задан; файл, загруженный в консолидацию, сохраняет свою (v5.18). Заменяется и файл с `CONFIRMED` @@ -725,6 +842,13 @@ replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; ` `PERIOD_REQUIRED` (поправьте дату отчёта, п. 9); непройденные неакцептованные жёсткие проверки §7 — 409 `CHECKS_NOT_PASSED` (список нарушений — в `detail.failures`: `column_index`, `relation`, `expected`, `actual`); порядок 409: `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` → `DOCUMENT_NOT_CONFIRMABLE` → `PERIOD_REQUIRED` → `CHECKS_NOT_PASSED`. + **С v2.0** (Р-13, Р-18) порядок: `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` → `DOCUMENT_NOT_CONFIRMABLE` → + `PERIOD_REQUIRED` → `LINES_UNRESOLVED` (есть строки `unreadable` / `missing`, у которых хотя бы в одной ячейке + непустая сумма или нераспознанный текст — `amount` или `string_value` не `null`; `detail.lines[] {line_id, row_index, + line_code_text}`; строки без значений не мешают) → `DUPLICATE_FORM` (в файле уже подтверждена другая форма того же + типа и периода — та же пара `period_year`, `period_quarter`; `detail.document_id`; между файлами — автозамена п. 3) → + `CHECKS_NOT_PASSED` (нарушения — записи `document_issue` вида `relation` без акцепта, у любой формы). + Автоподтверждения `LINES_UNRESOLVED` не касается (`done` таких строк не допускает). При подтверждении пишется `financial_statement_meta.reviewed_by/at`. Несовпадение контрагента (`MISMATCH`) и низкая уверенность значений подтверждение **не блокируют** (как у ОСВ): защита — статус `NEEDS_CHECK` и подсветка. Мягкие проверки (в том числе межформенная) не блокируют. `DISCLOSURE`/`OTHER` — без условий. Привязка к версиям и переходы статуса версии — @@ -756,7 +880,8 @@ replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; ` Правятся **суммы** ячеек (`values[] {line_id, column_id, amount}`) и три поля шапки: `inn` (10 или 12 цифр, иначе 422 `INVALID_INN`, в том числе `null` — очистить ИНН нельзя; пересчитывается `counterparty_check`), `report_date` (пересчитываются `period_year`, `period_quarter`, `reporting_date` по правилу периода п. 2) и `units` (непустая строка до 50 символов). Код и название строки, добавление и - удаление строк и колонок не правятся. **Только до подтверждения:** документ `CONFIRMED` — 409 `DOCUMENT_READONLY` + удаление строк и колонок не правятся (до v2.0; с v2.0 код и статус строки, дата колонки и наименование организации + правятся — см. ниже, название строки и удаление строк и колонок — по-прежнему нет). **Только до подтверждения:** документ `CONFIRMED` — 409 `DOCUMENT_READONLY` (исправление подтверждённой формы — новым файлом через автозамену, п. 3); файл `SUPERSEDED` — 409 `FILE_SUPERSEDED`; не форма — 409 `DOCUMENT_NOT_STATEMENT`; ячейка не этого документа — 422 `UNKNOWN_CELL`. Исходное сохраняется: у значения при первой правке `original_amount` = прежняя сумма, `is_modified = (amount ≠ original_amount)` (возврат к исходному снимает @@ -765,6 +890,45 @@ replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; ` без изменений — 200 без аудита. Автозамена при правке не запускается (только при маппинге): правка даты, давшая период другого файла ЮЛ, привязывает документ к версии лишь при подтверждении. Порядок блокировок — как у подтверждения (файл → документ). + **С v2.0** (Р-6, Р-13; change `unified-statement-normalization`) PATCH дополнительно принимает: + `header.organization_name` (непустая строка до 1000 символов; `organization_name_modified = true`, + `organization_name_original_value` не меняется; пересчитывается `counterparty_check`; снимает `NO_ORGANIZATION_NO_INN`; + аудит — как у ИНН); `header.report_date`, равный текущему, у поля из `needs_review_fields` — подтверждение даты без + изменения: поле уходит из `needs_review_fields`, снимается `REPORT_DATE_UNCERTAIN`, аудит (у бланка 66н только с + относительными подписями неуверенная дата иначе не подтверждается ничем, §11.9); + `values[] {line_id, column_id, confirmed: true | false}` — подтвердить ячейку без изменения (`is_confirmed`, + `confirmed_by/at`, `needs_review = false`, `is_modified` не ставится) или снять подтверждение (`is_confirmed = false`, + `needs_review` пересчитывается); `columns[] {column_id, period_date}` — дата колонке без подписи или с неуверенной + подписью (обязательная дата, `null` — 422 ошибка валидации; пересчитывает `is_current` и период документа; ставит + `period_kind = absolute`; та же дата у колонки с `COLUMN_DATE_UNCERTAIN` / `COLUMN_WITHOUT_DATE` — подтверждение, + предупреждение снимается); + `lines[] {line_id, line_code}` — назначить или сменить код; `lines[] {line_id, line_status: "detail"}` — отметить + расшифровкой; `lines[] {line_id, confirmed: true | false}` — подтвердить неуверенный код или снять подтверждение. + Переходы: `unreadable` / `missing` / `detail` → `ok` назначением кода; `unreadable` / `missing` / `ok` → `detail` + отметкой; `ok` → `ok` сменой кода. Обратного перехода `detail` → `unreadable` / `missing` нет: ошибочная отметка + исправляется назначением кода, исходное состояние — перезагрузкой файла. `line_status` в PATCH принимает только + `"detail"`: `"ok"` без кода — 422 `LINE_CODE_REQUIRED` (в `ok` строку переводит только `line_code`). Назначенный + код — `line_code_confidence = 100`, `line_code_modified`, `needs_review` строки = `false`; подтверждённый код — + флаг `is_confirmed`, `needs_review` строки = `false`, `line_code_confidence` и `line_code_modified` не меняются, + снятие подтверждения возвращает `needs_review` по прежнему правилу (Р-34); `line_code_original_value` не меняется (код из источника; у строк без кода — `null`); после правки родитель **всех** + строк `detail` пересчитывается как ближайшая строка `ok` выше по `row_index` (нет такой — `null`). Код — 4–5 цифр (422 `INVALID_LINE_CODE`), может + совпадать с существующим (Р-4); незнакомый словарю — `info`. **Строка, созданная правкой** — `row_index IS NULL` + (шаблонная): если строка с назначаемым кодом — такая и у неё есть суммы, — 409 `LINE_CODE_TAKEN`; без сумм — она + удаляется (её `id` убирается из `document_issue.line_ids`), назначенная строка источника занимает её место. Строки + источника (`row_index` не `null`) с тем же кодом не удаляются и не мешают — повтор кода законен. Элемент без действия + или с несколькими — 422 `INVALID_LINE_PATCH`; чужая строка — 422 `UNKNOWN_LINE`, чужая колонка — 422 `UNKNOWN_COLUMN`; + правка суммы по `line_code` при наличии строки `ok` с этим кодом — 422 `UNKNOWN_CELL` (передайте `line_id`). Удаления + строк источника нет (первичка не мутируется, `detail` — способ вывести строку из сумм). Строки, созданные правкой: + `row_index = null`, `source_ref = null`, `line_status = ok`, `line_code_original_value = null`. Правка + `header.report_date` пересчитывает `period_date` колонок с `period_kind = relative:*` общим кодом (`null` — `null`). + После правки ядро пересчитывает `checks`, `needs_review` и статус копией `arm_statements` (§11.9): правленое или + подтверждённое значение и строка не имеют `needs_review`, акцепт снимает жёсткие нарушения; записи `document_issue` + видов `relation` / `soft` заменяются целиком, предупреждения сервиса сохраняются, кроме снимаемых правкой (таблица + §11.9; значения строк `detail` — без `needs_review`, Р-23). **Акцепт** сбрасывается правкой данных — суммы, кода или + статуса строки, даты колонки, поля шапки; подтверждение ячейки или кода (`confirmed`) и подтверждение даты отчёта или + колонки тем же значением суммы не меняют и акцепт не сбрасывают (решение владельца 18.10.2026, Р-25). Расхождение `statements_version` + задания и копии ядра — `info` `STATEMENTS_VERSION_MISMATCH` (у заданий 1.x, где версии нет, — не пишется). Один PATCH — + один пересчёт и одна запись `RECOGNIZED_DATA_EDIT` (`before/after` по кодам, статусам, родителям, подтверждениям). 10. **Исходный файл и комментарии (Срез 3b).** `GET /files/{id}/content` (`DOCUMENT_VIEW`, policy по ЮЛ) отдаёт исходный объект из S3 потоком через ядро (в контуре MinIO для пользователя может быть недоступен, а через ядро действует видимость); работает и для `SUPERSEDED`; объекта нет в хранилище — 404 `FILE_CONTENT_NOT_FOUND`. Комментарии к документу — @@ -927,262 +1091,560 @@ OQ-92 (пустые «Организация»/«Группа компаний» --- -## §11. Маппинг JSON ИС Сканирование (FNR01/FNR02) → формы +## §11. Нормализация форм ББ/ОФР — общая цепочка (scan-gateway и tabular-parser) -Формат ответа — **по реальным файлам** `fixtures/ocr/aep_3.json`, `aep_3_ofr.json`, `aep_6.json` (проверено 04.10.2026; ТЗ -и первая редакция этого параграфа расходятся с ними — см. OQ-13, OQ-14, OQ-77): +**Статус: целевое правило** change `unified-statement-normalization` (RSHB/arm-ka-docs#116; дизайн — +`openspec/changes/unified-statement-normalization/design.md`, решения владельца Р-1…Р-19 от 17.10.2026), контракт +v2.0. Порядок выкладки — «сначала ядро» (ядро принимает 1.x и 2.0); пока сервис пишет 1.x, он работает по прежним +правилам — прежний текст §11, §11.1 и §12 (Срезы 2b, 3a, 3b) — в истории git репозитория docs, коммит `43b4b80`. +§11 объединяет прежние §11 (маппинг json ИС), §11.1 (очистка OCR-текста) и §12 (ББ и ОФР из Excel); §12 оставлен +заголовком-ссылкой. -- Каталог ответа `/` содержит **несколько json** — по одному на распознанный документ файла (пример: `aep_3.json` — - успех, `aep_3_ofr.json` — ошибка по второму документу того же PDF). Шлюз читает все json каталога и склеивает в один ответ - (`raw/{job_id}.json`: `{job_id, files[{name, response, parse_error}]}`); ошибка одного файла не отменяет остальные. -- Корень файла: `{file_uid, error {code, message}, data_ocr}`. **Успех — `error.code == ""`** (поле `error` не `null`, а - `{"code": "", "message": ""}`); ошибка — непустой `code` (`INVALID_DOCUMENT`, `FILE_READ_ERROR`, `INVALID_FORMAT`, - `RECOGNITION_FAILED`, `UNKNOWN_ERROR`), `data_ocr` с пустыми полями и `table_structure: []`. -- `data_ocr` — **объект** (не список): `label`, `organization`, `inn`, `units`, `date` — каждый `{form_name, confidence}` - (в `form_name` шумный OCR-текст; `date.form_name` ненадёжна, ИНН может быть мусором), `table_structure` — **список таблиц** - `{label {form_name, confidence}, rows[]}`: `label.form_name` «Бухгалтерский баланс» → BALANCE, «Отчет о финансовых - результатах» → INCOME. **Наблюдается:** в одном json может быть **несколько таблиц разных форм** (в `aep_3.json` и - `aep_6.json` — баланс и ОФР), каждая со своим `label`; тип документа задаёт `label`. Разбиение одной формы на несколько - таблиц (сегменты) в образцах **не встречалось** и не предполагается; если одна форма вдруг придёт двумя таблицами или - `label` повторится — это нештатный случай: документ не создаётся молча, а уходит в `NEEDS_CHECK` с причиной (решение — - Срез 3, OQ-77). Неизвестный `label` → `OTHER`. -- `rows[]`: `row_index`, `cells[] {column_index, value, confidence}`; `column_index` не обязательно подряд (бывают 0, 1, 2, 4, 5), - `value` — сырой OCR-текст (мусор в ячейках, переносы строк, скобки для отрицательных, пробелы-разделители тысяч). - В `aep_6.json` `rows[0]` — строка-шапка («Наименование показателя», «Код», «На 30 июня 2025 г», …); в `aep_3.json` шапки - **нет** — первая строка данных. Бывают строки без кода (расшифровки «в том числе…», OQ-36) и код-строка внутри ячейки - наименования («АКТИВ / I. Внеоборотные активы / Гудвил» с кодом 1105). -- `confidence` — **числа 0–100** (в ТЗ — доли 0..1; в реальных файлах проценты) → `numeric(5,2)` как есть. +Источник требований: ТЗ 6.2 («парсинг ОСВ, бухгалтерского баланса и ОФР»), DOC-037734, DOC-221281, FNR01/FNR02; формат — +Приказ 66н / ФСБУ 4/2023, на практике — скан бланка (ИС Сканирование), выгрузка 1С («Регламентированный отчёт»), шаблон +банка и вручную набитая таблица. -Маппинг (Срез 3): таблица `table_structure[i]` с `label` «Бухгалтерский баланс» / «Отчет о финансовых результатах» → -`document` (BALANCE/INCOME; `locator = file:<имя json>;table:` — так его формирует scan-gateway для текстового -PDF-пути, решение №1 спеки 3a; `pages:…` — только если ИС отдаст номера страниц) + `financial_statement_meta` -(ИНН, имя, units, report_date, okud — из `organization/inn/units/date.form_name`, с низкой уверенностью → `NEEDS_CHECK`) + -`financial_statement_column` (из шапки `rows[0]`, если она есть; `period_date` распарсить из «На 30 июня 2025 г», `is_current` = -первая; без шапки — колонки без дат, документ `NEEDS_CHECK`) + `financial_statement_line` (`cells[1]` код, `cells[0]` имя; -только строки с кодом) + `financial_statement_value` (число: убрать пробелы/`-`, скобки → минус; `confidence`; `is_recognized`). -Недостающие строки формы ядро достраивает на экране по справочнику редакции (§7, arm-core#80). -Сверки: ИНН/имя vs `legal_entity` → `document.counterparty_check`; дата vs период версии → `period_check`. +**Принципы.** -### §11.1 Очистка OCR-текста (перенос из старого ядра, Срез 3) +1. **Один выход на шаг.** Код, зависящий от источника, живёт только в шаге 1 (адаптеры «json ИС → кандидат», «лист + Excel → кандидат»). Шаги 2–8 — общий код с общими словарями. Единственное место, где общий код смотрит на источник, — + разбор запятой и точки в сумме (шаг 4). +2. **Ничего не выбрасывать молча.** Строка, колонка или ячейка, которую не удалось истолковать, идёт дальше со + статусом, а не превращается в счётчик; положение в источнике (`ref`), порядок строк (`row_index`) и сырой текст + ячейки (`text`) доезжают до базы и экрана. Подтвердить документ, не разобрав строки с суммами без кода, нельзя (Р-13). +3. **Качество — структурно и с привязкой к месту:** уверенность ячейки (от источника), `is_recognized` (разбор числа), + статус строки, нарушения §7 по ячейкам, предупреждения с уровнем и привязкой. Уверенность, `is_recognized` и §7 + сервис сводит в один признак `needs_review` у значения, строки и поля шапки и из них выводит статус документа. Ядро + при загрузке ничего из этого не пересчитывает. +4. **Один словарь — одно место:** проверки §7 (полный и упрощённый наборы), периоды, заголовки форм, подписи колонок, + упорядоченные перечни кодов форм по редакциям и вариантам (без наименований), диапазоны кодов — в общем коде + сервисов; соответствие «код → наименование» — только в ядре (Р-3). +5. **Очистка OCR — не отдельный шаг**, а первая стадия типовых разборов текста там, где уже известен смысл ячейки (дата, + подпись колонки, код, разлепление, сумма). -Источник — модуль старого ядра `DKB_ARMKA` `api/src/services/ofr_result_conversion/` (`utils.py`, `date_utils.py`, -`consts.py`; в локальном `reference-old-arm` тот же код лежит в `api/src/core/PDFWorker/`). Решение владельца -06.10.2026: модуль **проверен на реальном стенде ИС Сканирование, его код можно переносить** в нормализатор `scan-gateway`. -Это **единственное исключение** из правила «доменный код из `reference-old-arm` не переносить» (CLAUDE.md workspace). -Переносятся чистые функции очистки и разбора; обвязка старого ядра (`json_to_db_rows`, dataclass'ы `Meta/DbRow/DbValue`, -прямая запись в `financial_statement_*`) не переносится — результат ложится в `statementDocument` контракта v1.2 -(`string_value`, `confidence`, `is_recognized`, `period_date`, `period_text`, `line_code_confidence`). Перенос — с тестами -на `fixtures/ocr/aep_*.json`; изменения правил ниже — только через этот параграф. +**Где код.** Шаги 2–8, общие разборы и словари — пакет `arm_statements/` репозитория `arm-statements` (Р-1); в +tabular-parser, scan-gateway и arm-core он лежит копией (раскладывает и проверяет `sync-statements` в docs, прямые правки +копии запрещены, маркер версии — `service.statements_version`). Golden-тесты пакета — на синтетических и обезличенных +данных внутри пакета; тесты на `fixtures/` остаются в сервисах. Изменения правил ниже — только через этот параграф и +словари пакета. -1. **Буквы-двойники рядом с цифрами** (`date_utils._normalize`, `_LOOKALIKES`): `О/о/O/o → 0`, `З/з → 3`, - `l/I/|/і → 1`, `б → 6`, `S/s → 5`, `Ч → 4` — заменяются **только если символ стоит вплотную к цифре**, в несколько - проходов («2ОО5» → «2005»); слова («Общество») не портятся. Если первая строка значения состоит только из цифр, - пробелов, `|`, точек и двоеточий — `|` считается разделителем клеток формы («2|0|2|4|» → «2 0 2 4»), а не единицей. - **Применяется только к датам** — дате отчёта (шапка) и подписям колонок значений; коды строк очищаются п. 6 - (`clean_value`), суммы — п. 7 (`parse_amount`), ИНН — только цифры (п. 8). Замена двойников внутри кодов и сумм на - стенде не проверялась, поэтому не делается (решение №22 спеки 3a): код с латинской `O` вместо нуля не распознаётся - как целое и остаётся только в `raw/`. -2. **Даты из OCR-текста** (`date_utils._parse_date`): сначала текстовые формы по всей строке в порядке приоритета — - диапазон «с 01.01.2025 по 30.09.2025» (берётся конец), «30 сентября 2025 г./r./года» (латинская `r` вместо `г`), - «I квартал 2026» / «1-й кв.» / «первый квартал», «1 полугодие» / «1 п/г», «9 месяцев 2025», «январь – сентябрь 2025» - (последний месяц), «за 2025 г.» / «2025 год» (31.12); затем числовые — три группы цифр («30.09.2025», «2025 03 31», - «30/09/25»), иначе цифры склеиваются: 8 цифр → ДДММГГГГ / ГГГГММДД / ГГГГДДММ (первый валидный вариант), 4 → год - (31.12, только 1990–2100), 6 → ДДММГГ; хвост «0:00:00» отбрасывается. Двузначный год → 20YY. Период квартала/полугодия/ - N месяцев → последний день последнего месяца. Не дата (название организации) → `None`. Разборщик общий с §12 п. 4 - (Excel), там на вход приходят чистые строки. **Отличие от источника (решение №31 спеки 3a):** текст длиннее 200 - символов не разбирается (`None`, двойники к нему не применяются) — регулярки квартала и полугодия дают - экспоненциальное/кубическое backtracking на длинной строке; настоящая дата занимает десятки символов. -3. **Раскладка колонок** (`_build_layout`, `_infer_layout`): шапка — строка с ячейкой, начинающейся на «код» (после - нормализации: нижний регистр, всё не-буквенно-цифровое → пробел); наименование — ячейка с «наименование», иначе ближайшая - заполненная слева от кода; колонки значений — правее кода, где в тексте есть четыре цифры года или текст начинается на - «на » / «за ». Повторная шапка внутри таблицы **перезаписывает** раскладку. **Без шапки** раскладка выводится по данным: - код — столбец с наибольшим числом значений из справочника кодов строк; наименование — столбец левее кода с наибольшим - числом текстовых ячеек; значения — столбцы правее кода, где суммы не реже мусора (полоса скана) или столбец пуст - (период без данных); шапка синтезируется (`Код` / `Наименование показателя`, тексты периодов пустые). Строки до первой - настоящей шапки разбираются по выведенной раскладке. Ни шапки, ни столбца кода — таблица пропускается с warning. - **Рулинг 06.10.2026 (вместо прежнего «без шапки → колонки без дат, `NEEDS_CHECK`»):** раскладку выводить, как в старом - ядре, но документ без шапки всё равно отдавать `needs_check` с причиной в `metrics.warnings` — колонки без `period_date` - аналитик подтверждает руками. -4. **Классы строк** (`_classify_row`): пустой код → строка пропускается; нечисловой код → заголовок (не данные); - наименование «Баланс» → `BALANCE`, начинается с «итого» → `TOTAL`, иначе обычная строка. Берутся только строки с - целым кодом (как в §11 выше); расшифровки без кода — OQ-36. Строки, отброшенные классификацией, **не восстанавливаются** - (поведение источника), но о них выдаётся предупреждение уровня таблицы (решение №29 спеки 3a): `строк с нечисловым - кодом пропущено: N` — код непустой и не целое число (`1150 :`); `строк без кода с суммами пропущено: N` — код пуст, но в - колонках значений есть разобранная сумма (в `aep_3.json` «БАЛАНС» и «Выручка» имеют пустой код, мусор `211(1 |` стоит в - колонке значения; как в Excel-пути, OQ-141). Строки с пустым кодом без сумм (заголовки групп, «в том числе:») - предупреждения не дают. Любая такая таблица — `needs_check`; восстановление строк по наименованию — OQ-149. - **Склеенные строки** (OQ-156, решение владельца 08.10.2026): ИС может вернуть две соседние строки формы одной строкой — - в ячейке кода несколько кодов через перевод строки, суммы тоже через перевод строки (ББ РБ и Счастливочной за 1 кв. - 2025: код `1500\n1700`, наименование `Итого по разделу V\n\nБАЛАНС`, суммы `917 775\n2 371 333`). До классификации - такая строка **разлепляется**: если в ячейке кода N ≥ 2 непустых частей, каждая — целое число, и в каждой ячейке - колонок значений либо пусто, либо ровно N непустых частей, получается N строк: i-й код, i-я часть каждой ячейки - значений (пустая ячейка — пусто во всех), наименование — i-я из непустых частей, если их тоже N, иначе всё - наименование у каждой; уверенность — исходной ячейки. Предупреждение уровня таблицы `строк с несколькими кодами - разлеплено: N` (документ `needs_check`: разбивку подтверждает аналитик). Число частей не сходится хотя бы в одной - ячейке — строка не разлепляется и отбрасывается как нечисловой код (предупреждение выше). - **Повтор кода** в таблице: остаётся первая строка, остальные не попадают в документ, выдаётся предупреждение «код N - повторяется: оставлена первая строка», документ `needs_check` (как §12 п. 5: в БД `financial_statement_line` - уникален по `(meta_id, line_code)`; старое ядро дубли не убирало — решение №15 спеки 3a). Известный недостаток: в - `aep_6` (ОФР) код 2410 повторён, первая строка пуста, а отброшенная содержит `(649)` — OQ-152 (сумма отброшенной - строки остаётся в `raw/`). -5. **Тип таблицы** (`_resolve_table_type`): по первой цифре кодов строк данных — больше `1xxx` → BALANCE, больше `2xxx` → - INCOME; это запасной путь, когда `label.form_name` зашумлён; при расхождении с `label` — `needs_check`. -6. **Код и наименование строки**: код очищается до букв, цифр, пробела и точки (`_clear_value`: переносы строк и - табуляции → пробел, остальное выбрасывается); наименование берётся **из справочника по коду** (`consts.codes_mapping`; - у нас — `api/rules/statement_lines.yaml` **scan-gateway** с редакциями форм `66n` / `fsbu4`, см. п. 10, а не - `rules/statement_lines.yaml` из §12 — в tabular-parser такого файла нет), а из OCR — только если кода нет в справочнике - выбранной редакции (`line_code_confidence < 100`, не выше 99). Код считается распознанным (`line_code_recognized`), если - `confidence == 100`, очищенный текст равен сырому и код есть в справочнике. -7. **Суммы** (`_parse_amount`): пробелы убираются; **сначала разделитель тысяч** (см. ниже), затем запятая → точка; - содержимое скобок — отрицательное; из строки - удаляется всё, кроме цифр, `+`, `-`, `.`; если точек больше одной, **удаляются все** (запятая уже стала точкой: - `1.234,56` → `123456`, дробная часть не сохраняется, `is_recognized = false`); `Decimal`, иначе `None`. Прочерк, «–», «—», пусто — пустое значение (NULL, не 0). `string_value` — текст ячейки как распознан - (`\n`/`\t` → пробел), **только если сумма не разобрана** (`amount = null`) и ячейка не пуста и не прочерк (DBML: - «как распознано, если не число»); у разобранных сумм — `null` (решение №16 спеки 3a). - **Перенесено дословно (расхождения с желаемым — OQ-147, OQ-148):** закрывающая скобка, распознанная как `}` или `]` - (`(7744536}`), минус не ставит — сумма положительная, `is_recognized = false` (решение №12); длинное/среднее тире - «—»/«–» при `confidence = 100` даёт `is_recognized = false` (тире вырезается как «лишний» символ), дефис «-» — `true` - (решение №20). Скобки сами `is_recognized` не портят: признак считается до постановки минуса (решение №13; в источнике - любая сумма в скобках получала `false`). - **Разделитель тысяч** (AURORA-1405, scan-gateway#35, решение владельца 15.10.2026; отличие от источника, где «4,200» в - PDF означало 4 200, а в прежнем АРМ показывалось как 4,2; в scan-gateway до этого правила ячейка отсекалась решением №30: - `amount = null`, текст в `string_value`): если текст ячейки после `strip` и удаления пробелов, с учётом скобок - (шаблон применяется к содержимому скобок, только если пара скобок занимает всю строку) и знака, полностью совпадает с `[+-]?[1-9]\d{0,2}(,\d{3})+`, запятые — разделители - тысяч и **удаляются до** замены запятой на точку: `4,200` → 4200, `(4,200)` → −4200, `1,234,567` → 1234567, `-4,200` → - −4200, `+4,200` → 4200. Удаление таких запятых не считается отбрасыванием символов: `is_recognized` определяется только - `confidence == 100`. Остальное без изменений: `4,2` → 4.2, `12,34` → 12.34, `0,125` → 0.125 (далее отсекается решением - №30), `1,23` → 1.23, `1 234,56` → 1234.56, `1.234,56` и `1,234.56` → 123456 (`is_recognized = false`); `12,3456`, - `4,200,`, `(4,200}`, `(4,200) руб`, `abc(4,200)` и `(4,200)(1,000)` шаблону не соответствуют, разбор прежний. - **Точка — разделитель тысяч** (AURORA-1405, scan-gateway#37, решение владельца 16.10.2026: ИС Сканирование местами - возвращает «4.200», «837.994»; такие суммы разбирались как 4.200 с тремя знаками и отсекались решением №30 в - `amount = null`, текст в `string_value`): то же правило, но шаблон шире (решение владельца 16.10.2026: если после точки ровно три цифры — это разделитель тысяч независимо от числа цифр до неё; если суммы не сойдутся, аналитик увидит это на проверках): `[+-]?[1-9]\d*(\.\d{3})+` — те же условия - (весь текст ячейки после `strip` и удаления пробелов или содержимое пары скобок на всю строку), точки удаляются, - `is_recognized` определяется только `confidence == 100`: `4.200` → 4200, `837.994` → 837994, `(4.200)` → −4200, - `-4.200` → −4200, `1.234.567` → 1234567 (раньше при нескольких точках — `is_recognized = false`), `1234.567` → - 1234567, `12345.678` → 12345678, `1 234.567` → 1234567, `(1 234.567)` → −1234567. Остальное без изменений: `4.2` → 4.2, - `4.20` → 4.20, `12.34` → 12.34, `1234.56` → 1234.56 (дробная часть не из трёх цифр — дробь); `0.125` и `01.234` (первая - цифра нулевая — не тысячи) → дробь, при трёх знаках отсекается решением №30; `1.2345` — текст. Правило для запятой - этим **не расширяется**: `1234,567` и `1 234,567` по-прежнему дробь 1234.567 (отсекается №30), тысячи — только - `\d{0,2}` цифр до первой запятой. Смешанные записи - (`1.234,56`, `1,234.56`, `1.234,567`) и «грязные» (`4.200.`, `(4.200}`, `(4.200) руб`, `1.234.56`) шаблону не - соответствуют, разбор прежний. Правило действует только на ячейки колонок значений таблицы ББ/ОФР (`parse_amount` не - вызывается для дат, кодов строк, ОКЕИ, ИНН); дробь с тремя знаками после точки и так не принималась (`Money`, №30). - **Отличия от источника под контракт** (решение №30 спеки 3a): сумма с **более чем двумя** знаками после запятой - (например `0,125`, `12.3456`) не укладывается в `Money` контракта (`numeric(18,2)`) — `amount = null`, `string_value` — - текст ячейки, `is_recognized = false` (иначе один артефакт OCR валил всю таблицу); `1,234` под это не попадает — - разбирается правилом тысяч выше (= 1234); - `confidence` ячейки и полей шапки приводится к диапазону 0–100, NaN → 0. - **`is_recognized`** значения = `confidence == 100` **и** разбор суммы не потребовал выбрасывать символы (сырой текст без - скобок равен очищенному). Для ячеек шапки: `is_recognized` = `confidence == 100` и в тексте не было мусора; если дата - разобралась — `period_text` нормализуется в «На ДД.ММ.ГГГГ», `period_date` — результат п. 2. Ячейки под колонкой - заголовка нет — пустое значение с `is_recognized = false`, чтобы не сдвигать столбцы. -8. **Шапка документа**: ИНН — только цифры из `inn.form_name`, «распознан», если `confidence == 100` и в сыром тексте не - было ничего, кроме цифр (предупреждение уровня файла: `ИНН не распознан` — в поле нет ни одной цифры; `ИНН распознан - неуверенно` — цифры извлечены, но `confidence` ≠ 100 или убран мусор); единицы — `_clear_value`, тот же критерий; дата отчёта — п. 2, «распознана», если - `confidence == 100` и дата разобралась. Нераспознанное → `needs_check` (как в §11). -9. **Ошибка разбора одной таблицы** (исключение в разборе `table_structure[i]`) не роняет файл: выдаётся предупреждение - уровня таблицы `BACKEND_PARSING_ERROR: <класс исключения>: <сообщение>`, остальные таблицы файла разбираются; - хотя бы один документ есть — конверт `needs_check` (статус не бывает `done`, пока есть предупреждение уровня таблицы), - ни одной таблицы не разобрано — `error` с кодом `RECOGNITION_FAILED` («ни одной таблицы не разобрано», причины — в - `metrics.warnings`). Ошибочный файл набора ответа при успешном соседе (например, `_ofr.json` с - `INVALID_DOCUMENT`) документы успешных файлов не отменяет: ошибка попадает в `metrics.warnings` строкой - `<имя json>: <код ИС>: <сообщение>`, такой файл **понижает статус до `needs_check`** (`done` невозможен, решение №28 спеки 3a: потерянный документ, например ОФР, - не должен остаться незамеченным). -10. **Редакции форм** (`api/rules/statement_lines.yaml`): `66n` (Приказ Минфина № 66н, отчётность по 2024 год включительно) - и `fsbu4` (ФСБУ 4/2023, с 2025 года; **пока пуста** — состав кодов даёт владелец, OQ-150). Редакция документа выбирается - **по дате отчёта** шапки, если её нет — по **самой поздней `period_date`** колонок значений, если нет и её — по - **числу совпадений кодов** строк со словарями редакций (с предупреждением «редакция определена по кодам»); если и кодов - не хватает — редакция **не определена**: имена строк из OCR, предупреждение «редакция не определена», `needs_check`. - Имя строки — из словаря выбранной редакции по коду, из OCR — только если кода в нём нет. **Пустая** выбранная - редакция → имена строк из OCR, одно предупреждение «редакция `` не заполнена», `needs_check`. Предупреждение «код N из редакции X» - (код есть в другой редакции, но не в выбранной) выдаётся только для **непустой** выбранной редакции — иначе каждая - строка формы 2025 года давала бы шум (решение №23 спеки 3a). Редакция формы в конверт не передаётся (OQ-145). +### §11.1 Источник OCR: ИС Сканирование (адаптер шага 1 в scan-gateway) + +**Формат ответа** — по реальным файлам `fixtures/ocr/*.json` (проверено 04.10.2026; OQ-13, OQ-14, OQ-77): + +- Каталог ответа `/` содержит **несколько json** — по одному на распознанный комплект отчётности. Шлюз читает + все json каталога и склеивает их в `raw/{job_id}.json` (`{job_id, files[{name, response, parse_error}]}`) **до** + нормализации; ошибка одного json не отменяет остальные. Допущение владельца (17.10.2026): несколько периодов в одном + файле ИС отдаёт разными json; образца нет — проверить на первом таком файле (OQ-167). +- Корень: `{file_uid, error {code, message}, data_ocr}`. **Успех — `error.code == ""`**; ошибка — непустой `code` + (`INVALID_DOCUMENT`, `FILE_READ_ERROR`, `INVALID_FORMAT`, `RECOGNITION_FAILED`, `UNKNOWN_ERROR`). +- `data_ocr` — объект: поля шапки `label`, `organization`, `inn`, `units`, `date` — каждое `{form_name, confidence}` (сырой + OCR-текст; `date.form_name` ненадёжна — у aep_6 и «БО 1 кв 2025» там название организации), **одна шапка на json**; + `table_structure[]` — таблицы `{label {form_name, confidence}, rows[{row_index, cells[{column_index, value, + confidence}]}]}`; `column_index` не обязательно подряд; `confidence` — проценты 0–100 (NaN → 0). В одном json + встречаются ББ и ОФР одного периода под общей шапкой. ИС отдаёт ячейку на каждую позицию строки: пустая — пустой + текст с уверенностью 100, непрочитанная — пустой текст с уверенностью 0. + +**Адаптер.** Каждая таблица успешного json — кандидат (`FormCandidate`): `source = ocr`; `locator = file:<имя +json>;table:` (как в golden-эталонах); шапка json копируется в каждую свою таблицу (`Cell.text = form_name`, +`confidence` ИС); `label.form_name` таблицы — заголовок в `evidence` (тип — правилом §11.2 «тип-подсказка» по словарю +заголовков; прежний `statement_labels.yaml` уходит в словарь пакета); сетка строится по `row_index` и `column_index` +плотно, пустая позиция — `null`; `Cell.ref = row:`, `value = null`. + +**Ошибки набора.** Ошибочный json (`parse_error`, непустой `error.code`, корень не объект) при хотя бы одном успешном +соседе — предупреждение файла `JSON_FAILED` (`error`, `locator = file:<имя json>`, в тексте код и сообщение ИС; решение +№ 28 среза 3a: потерянный документ, например ОФР, не должен остаться незамеченным); все json ошибочны — конверт `error` +с кодом первого. Исключение при разборе таблицы — предупреждение **файла** `TABLE_PARSE_ERROR` (`error`, `locator = +file:<имя json>;table:`; документа у такой таблицы нет), остальные таблицы разбираются; как и `JSON_FAILED`, оно +блокирует `RECOGNIZED` всех документов файла (§9.3); ни одной таблицы не разобрано — `error RECOGNITION_FAILED`. + +**Перенос из старого ядра.** Источник очистки и разборов — модуль `DKB_ARMKA` `api/src/services/ofr_result_conversion/` +(`utils.py`, `date_utils.py`, `consts.py`; локально — `reference-old-arm/api/src/core/PDFWorker/`); решение владельца +06.10.2026: проверен на стенде ИС Сканирование, перенос разрешён — **единственное исключение** из правила «доменный код из +`reference-old-arm` не переносить». Перенесённые функции теперь — общие разборы пакета (§11.3–§11.6), отличия от +источника перечислены там. + +### §11.2 Источник Excel: tabular-parser (адаптер шага 1) + +**Остаётся в сервисе:** лимиты и чтение книги (`PARSE_MAX_BYTES` 50 МБ, `PARSE_TIMEOUT_SECONDS` 120 с, формат по +сигнатуре байтов — OLE2 → XLS, zip с `xl/workbook.bin` → XLSB, иной zip → XLSX, иначе `UNSUPPORTED_FORMAT`; несовпадение +с `file_type` — `FILE_TYPE_MISMATCH` `info`; лимиты распакованного zip и площади объединённых ячеек; библиотека упала — +`UNREADABLE`), классификация листов и путь ОСВ (§1). + +1. **Классификация листов**: лист ОСВ (признак ведомости в первых 30 строках, §1) → форма → продолжение → прочее. Форма — + заголовок («Бухгалтерский баланс», «Отчет о финансовых результатах», «…о прибылях и убытках») или ОКУД в любой строке до + таблицы; титул «Бухгалтерская отчетность» — не тип. Продолжение — лист без заголовка, но с маркером «Форма … с.2», или + с подписью «Код» и периодами правее, или с колонкой кодов, или с одним-двумя кодами сразу после листа формы. Прочее — + `SHEET_OTHER` (`info`), если у листа нет ни типа, ни кодов. +2. **Блоки форм на листе**: строки с заголовком или ОКУД — кандидаты; без кодов между ними блоки сливаются, заголовок + после кодов — новая форма. Шапка **своя у каждого блока** в его границах: организация (после метки «Организация», + иначе текст с признаком ОПФ строкой выше метки), ИНН 10/12 цифр у метки «ИНН» (ИНН аудитора не берётся: строки после + метки «аудитор» исключены), ОКПО, ОКОПФ, ОКФС, ОКЕИ, адрес, единицы, дата (подзаголовок формы или тройка «число | месяц + | год» у метки «Дата»; для ОФР — конец периода). Шапка другого листа не используется. Найденное поле — `Cell` с + `confidence = 100`, не найденное — `null`. ОКЕИ — поле `okei` шапки контракта. +3. **Ячейки**: `text` — как показывает числовой формат ячейки, `value` — число по формату (tp#42, OQ-138 пересмотрен + 15.10.2026): масштабирующие запятые (`#,##0,` → тысячи), число знаков после точки, секции и условия (`[=0]"-"`, + `[=-646532]"(647)"`; литерал условной секции разбирается как текст), округление half-up, знак — от сырого значения, + условия сравниваются с точностью 15 значащих цифр; `General` / пустой формат — значение как в ячейке; формат, который не + удалось разобрать (`%`, экспонента, дробь, даты, `@`, неизвестные знаки вне кавычек, больше четырёх секций, условие не + по шаблону, результат вне `numeric(18,2)`), — значение как в ячейке и одно `NUMBER_FORMAT_UNPARSED` (`info`) на документ + с перечнем форматов; xlsb — стили не читаются, `XLSB_NO_FORMATS` (`info`). `ref = <лист>!` по правилу Excel: имя + листа берётся в одинарные кавычки, если в нём есть что-то кроме букв, цифр и `_` или оно начинается с цифры, апостроф + внутри имени удваивается (`Баланс!C27`, `'Бухгалтерский баланс. Лист 1'!C14`; решение владельца 18.10.2026, Р-22 / + S-15); `confidence = 100`. +4. **Склейка листов-продолжений — до раскладки.** Строки продолжения дописываются в конец сетки открытой формы по + маркеру «Форма … с.2» или по позиции листа; граница листа — в `segment_breaks` (всегда граница сегмента раскладки). + Тип маркера сверяется с типом формы: расхождение — отдельный кандидат и `CONTINUATION_TYPE_CONFLICT` (`error`). Лист без + маркера и шапки присоединяется, только если его коды в диапазоне открытой формы и, при трёх и более кодах, число колонок + сумм совпадает с формой (проверки прежнего `join_blocker`); иначе — отдельный кандидат и `SHEET_NOT_JOINED` (`error`). + `locator = sheet:[,…]`. **Страницы в разных файлах не склеиваются** (1С печатает форму постранично): страница 2 + без первой — документ без шапки, `needs_check` (OQ-140). Две половины формы одного типа и периода в одном файле + склеиваются только листом-продолжением; иначе это две формы (Р-15, вопрос — OQ-165). +5. **Пятизначные подстроки** (11101, 12301…) — обычные строки. **Шаблоны банка** (коды есть, сумм и шапки нет) — + `needs_check` (OQ-142). + +**Тип-подсказка (`type_hint`) — одно правило у обоих источников:** ОКУД по словарю (0710001 → `BALANCE`, 0710002 → +`INCOME`, прочие 0710xxx — 0710003 капитал, 0710004 ДДС и др. → `DISCLOSURE`) → иначе заголовок по словарю заголовков +(у OCR — `label` таблицы ИС; «Пояснения», «Расшифровка» → `DISCLOSURE`) → иначе `null`. Окончательный тип — по кодам строк +(§11.7). + +**Шапка — одно правило разбора** по `Cell.text`: ИНН — только цифры, **без замены двойников** (иначе «3|1 21 2|» из aep_3 +даёт лишние единицы и `MISMATCH` с ЮЛ), длина 10 или 12, иначе `null`; дата отчёта — словарь периодов §11.3 п. 3; +единицы — словарь единиц пакета `statement_units.yaml` (написания «тыс. руб.», «в тыс. рублей», «руб.», «млн руб.» → `тыс. +руб.` / `руб.` / `млн руб.`); единицы не найдены по тексту, а ОКЕИ есть — по коду ОКЕИ тем же словарём (384 → `тыс. руб.`, +383 → `руб.`, 385 → `млн руб.`); ОКЕИ — только цифры. Сырой текст и уверенность каждого поля — в контракт +(`<поле>_text`, `<поле>_confidence`); ядро хранит их в `financial_statement_meta.header_source`. + +### §11.3 Шаг 2 — раскладка таблицы + +**Очистка OCR-текста** (общий разбор, применяется к любой ячейке без `value`; у Excel — к текстовым ячейкам, почти +всегда без изменений). Буквы-двойники рядом с цифрами `О/о/O/o → 0`, `З/з → 3`, `б → 6`, `S/s → 5`, `I/l/|/і → 1`, +`Ч → 4` заменяются **только вплотную к цифре**, в несколько проходов («2ОО5» → «2005»), слова не портятся; если первая +строка значения состоит только из цифр, пробелов, `|`, точек и двоеточий — `|` считается разделителем клеток («2|0|2|4|» → +«2 0 2 4»). **Применяется к датам, подписям колонок, кодам и суммам** (Р-11; отменяет прежнее «только к датам» и решение +№ 22 среза 3a): сумма с заменой — `is_recognized = false`, код с заменой — неуверенный (уверенность не выше 99); в суммах +крайние `|` (след границы таблицы: «| : 122477») удаляются, а не заменяются. ИНН двойники не трогают. Текст длиннее 200 +символов датой не разбирается (решение № 31 среза 3a: backtracking регулярок). + +**Диапазоны кодов** (словарь пакета): ББ 1100–1799, ОФР 2100–2999, отчёт об изменениях капитала 3100–3699, отчёт о +движении денежных средств 4100–4499, пояснения 5000–5999, отчёт о целевом использовании средств 6100–6499. «Кодовые +диапазоны» для поиска столбца — объединение отрезков (годы 2000–2099 и четырёхзначные суммы вне отрезков кодами не +считаются). «Диапазон столбца» — отрезок, в который попадает большинство кодов найденного столбца; им проверяются +разлепление строк, слабые продолжения и наследование столбца в сегментах. + +**Разбор кода:** убрать пробелы и переносы → двойники (`|` → 1, только если результат в кодовом диапазоне: «| 140» → +1140) → краевая пунктуация («1220 ,» → 1220) → 4–5 цифр (допускается `.0`: `1110.0` из Excel), первые четыре — в кодовом +диапазоне. Многострочная ячейка — код, только если каждая её часть код. + +1. **Столбец кодов.** (а) Столбец с подписью из словаря («код», «коды», «код строки», «код показателя» — после + нормализации регистра и пунктуации) над первой строкой с кодом, если под подписью доля кодов среди непустых ≥ 0,6 или + кодов ≥ 3; несколько кандидатов — с наибольшим числом кодов; берётся последняя такая подпись над первой строкой с кодом. + Защита обязательна: в выгрузках 1С под «Коды» титульного блока стоят ОКУД, дата, ОКПО, ИНН, ОКОПФ (12267, 12300 — «коды + баланса» по первым цифрам), ОКЕИ — один код из девяти, доля 0,11. Документ из двух строк остаётся документом (два из + двух, доля 1,0). (б) Подписи нет — столбец с наибольшим числом ячеек-кодов, кодов ≥ 3, доля ≥ 0,6. Нет — + `CODE_COLUMN_NOT_FOUND`; сопоставление по наименованиям строк не делается (OQ-139). +2. **Сегменты.** Новый сегмент открывает строка-шапка (в ней есть подпись кода из словаря, или не менее двух её непустых + ячеек совпадают после нормализации с ячейками шапки первого сегмента) или граница листа из `segment_breaks`. У + сегмента своя раскладка: по его шапке; без шапки — столбец кодов по п. 1, а если сегмент не первый и кодов меньше трёх — + ослабленный поиск: столбец с наибольшим числом кодов из диапазона столбца первого сегмента, минимум один (лист «_2» НПЗ + с единственной строкой 2400, у которого код в другой колонке). Колонки документа задаёт первый сегмент; колонки + следующих сегментов новых колонок не создают, а сопоставляются с колонками первого: у сегмента **со своей шапкой** + (повторная шапка OCR, лист-продолжение Excel со строкой заголовка — «Бухгалтерский баланс. Лист 4» НПЗ, колонки 8–10 + при 16 / 21 / 24 у первого листа) — по `period_date`, если у каждой колонки сегмента дата есть и совпадает ровно с + одной колонкой первого, иначе по порядку; у сегмента без шапки колонки значений — столбцы правее кода, где в строках с + кодами числа, по порядку. Значения несут `column_index` колонки первого сегмента. При сопоставлении по порядку другое + число колонок — `SEGMENT_COLUMNS_MISMATCH` (`error`): значения по позиции, лишние столбцы сегмента в колонки не + попадают, их номера — в тексте предупреждения (S-56). Столбец кодов не найден ни так, ни так — + `SEGMENT_WITHOUT_CODES` (`error`); строки сегмента не теряются (роль `data`, статусы по §11.4). +3. **Дата колонки — один словарь периодов** `statement_periods.yaml`, объединяющий прежний словарь парсера и шаблоны + `dates.py` шлюза (порядок приоритета текстовых форм: диапазон «с 01.01.2025 по 30.09.2025» — конец; «30 сентября 2025 + г./r./года»; «I квартал 2026» / «1-й кв.» / «первый квартал»; «1 полугодие» / «1 п/г»; «9 месяцев 2025»; «январь – + сентябрь 2025» — последний месяц; «за 2025 г.» / «2025 год» — 31.12; затем числовые: три группы цифр «30.09.2025», + «2025 03 31», «30/09/25»; иначе склеенные цифры — 8 → ДДММГГГГ / ГГГГММДД / ГГГГДДММ (первый валидный), 4 → год (31.12, + 1990–2100), 6 → ДДММГГ; хвост «0:00:00» отбрасывается; двузначный год → 20YY; квартал / полугодие / N месяцев → последний + день последнего месяца; не дата → `null`). Строки `test_dates.py::test_parses_real_forms` шлюза — обязательные тесты + словаря. Результат — `period_date` (конец периода колонки; во внутренних типах пакета может называться иначе, в + контракте и БД — `period_date`) и `period_kind`: + - абсолютные подписи → `absolute`; + - относительные подписи бланка 66н без впечатанных дат (Р-6): «На отчётную дату», «За отчётный период» → + `relative:current` (= эффективная дата); «На 31 декабря предыдущего года» → `relative:prev_year_end` (31.12 года + эффективной даты − 1); «На 31 декабря года, предшествующего предыдущему» → `relative:prev_prev_year_end` (31.12 года + − 2); «За аналогичный период предыдущего года» → `relative:prev_period` (эффективная дата − 1 год; 29.02 → 28.02); + - подписи нет → `none`. + + **Эффективная дата документа** — дата отчёта из шапки, иначе самая поздняя абсолютная дата колонок (та же формула, + что период формы в ядре, §9.5 п. 2); нет ни той, ни другой → у относительных колонок `period_date = null`, вид + остаётся относительным, ядро досчитает после правки даты отчёта. Подпись OCR с уверенностью ниже 100, разобранная в + дату, подтверждается, если дата входит в ожидаемые: эффективная дата, она же минус один и минус два года, 31 декабря + предыдущего и предшествующего ему года; иначе `COLUMN_DATE_UNCERTAIN` (`error`, колонка). Для этого подтверждения + эффективная дата строится **только из уверенных источников** — дата отчёта с уверенностью 100, иначе самая поздняя + абсолютная дата колонок с подписью уверенности 100; нет таких — подпись не подтверждена (неуверенное не подтверждает + само себя, S-64). +4. **Строка заголовка** — последняя над первой строкой с кодом, где в столбце кодов стоит подпись кода; иначе последняя + над первой строкой с кодом с двумя и более подписями-периодами правее кода (или одной, не голым годом); двухстрочный + заголовок («На» + «20__ г.», перенос внутри ячейки) склеивается. Роль `title` — только строки выше найденной строки + заголовка (титульный блок «Дата 30 | 9 | 2025»); в сегменте без заголовка роли `title` нет, строки выше первого кода с + содержимым идут в данные. Заголовка нет ни у одного сегмента → колонки значений первого сегмента — столбцы правее кода, + где в строках с кодами числа, `label` пустая, `period_date = null`, `period_kind = none`, одно `HEADER_ROW_NOT_FOUND` + (`error`; прежний синтез шапки шлюза и «рулинг 06.10.2026» заменены этим правилом). Заголовок есть хотя бы у одного + сегмента — остальные берут его колонки без предупреждения (листы 2–5 ББ НПЗ без шапки остаются `done`). +5. **Строка нумерации** «1 2 3 4 5» — роль `numbering`, в данные не попадает (AURORA-1517). Строка — нумерация, если в + ней не меньше трёх непустых ячеек, каждая после замены букв-двойников (§11.3, «1 2 З 4» → 1 2 3 4) — целое число, + числа слева направо возрастают на 1, начинаются с 1 или 2 и допускают **один пропуск** («1 2 4 5», «2 3 4 5» без + первой ячейки; в Excel Межениновской в столбце кодов строк нумерации стоит «2»). Решение владельца 18.10.2026 (Р-22, + S-13): иначе сбой OCR в нумерации превращает её в строку `unreadable` с «суммами» и блокирует подтверждение. + Запись нумерации в столбце кодов («2», «3») кодом не считается — вне кодовых диапазонов. +6. **Подпись без периода** (Р-6): период по словарю (абсолютный или относительный) → колонка с датой; подпись из + словаря исключений («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») → пропуск, `COLUMN_SKIPPED` (`info`); + иначе, если в столбце есть числа в строках с кодами (OCR-мусор «| | г» с уверенностью 17, обрезанная или самодельная + подпись), — колонка остаётся с подписью как есть, `period_date = null`, `period_kind = none`, `COLUMN_WITHOUT_DATE` + (`error`, колонка); аналитик ставит дату через PATCH (§9.5 п. 9); иначе пропуск. Колонка без даты не мешает периоду + формы (эффективная дата), но межформенная проверка и гудвилл «на отчётную дату» по ней не считаются, пока дата не + проставлена. Уверенность подписи — `label_confidence` (Excel — 100, подписи нет — `null`). +7. **Столбец наименования** — подпись, начинающаяся с «наименование», в строке заголовка; иначе столбец левее кода с + наибольшим числом текстов, кроме «Пояснения» (пустые или ≤ 3 символов); наименование строки — ячейка этого столбца, + пуста — ближайшая текстовая ячейка левее кода, кроме «Пояснения». +8. **Номер колонки** (`columns[].column_index`, `values[].column_index`, `column_index` предупреждений) — номер столбца + в сетке кандидата шага 1: у OCR — `column_index` ИС как есть (не обязательно подряд: 2, 4, 5), у Excel — номер + столбца листа с 0 (столбец D — 3). Номер уникален в документе; колонки сегментов без шапки сопоставляются с колонками + первого сегмента по порядку и несут их номера. Предупреждение о столбце, которого нет среди `columns[]` + (`COLUMN_SKIPPED`), несёт номер этого столбца в сетке; ядро хранит его в `document_issue.column_index` (колонки формы + нет — `column_id = null`). + +### §11.4 Шаг 3 — отбор строк данных и статусы + +Строка данных — строка сетки с ролью `data`, у которой есть **содержимое**: в ячейке кода есть хотя бы одна цифра +(разобранный код или мусор вроде «44 30\nI |» aep_6), или хотя бы одна ячейка значений даёт сумму (`amount` не `null`), +или непуста с уверенностью ниже 100 (признак потерянной суммы: «[190 :», «" :» в aep_3), или её текст — «цифра под видом +буквы» (после замены двойников без условия соседства есть цифра и нет других букв: `'I'` с уверенностью 100 в строке 38 +aep_6). Строка только с текстом наименования — не строка данных (заголовок раздела); текст из настоящих букв в колонке +значений при уверенности 100 (подписи под формой Excel: «Руководитель … Косарев») — тоже нет, осознанное исключение +(S-72). Строка `unreadable` без значений («44 30\nI |» при пустых суммах) +— строка данных: документ `needs_check` (не все строки `ok` / `detail`), но подтверждение она не блокирует +(`LINES_UNRESOLVED` — только строки со значениями, §9.5 п. 4). У строки: `row_index` (номер в документе с +0, по порядку сетки после разлепления), `ref` (уникален в документе: OCR `row:`, разлепленные +`row:#` с k от 1; Excel — адрес ячейки столбца кодов строки `<лист>!`, без столбца кодов — первой непустой +ячейки), статус, ячейки кода и наименования, ячейки значений по колонкам сегмента, `parent_ref`. **Статусы:** + +- `ok` — код разобран; +- `unreadable` — ячейка кода содержит хотя бы одну цифру, но кодом не разобралась («ИЧ,250», «[190 :»); ячейка без + единой цифры («}», «:», «изо :» — двойники вне соседства с цифрой цифрами не считаются, S-61) считается пустой; +- `detail` (Р-8) — строка без кода с содержимым, над которой есть строка `ok` (в том же или в предыдущих сегментах — + родитель продолжается через разрыв страницы): расшифровка («в том числе: …», «из них …», «Оборудование к установке», + «Товар (41.01)»); родитель — ближайшая строка `ok` выше (`unreadable` / `missing` между ними пропускаются). Сервисы её + отдают, ядро хранит; в проверки §7, агрегаты, `needs_review` и `done` она не входит (у её значений `needs_review = + false`, Р-23); экран по умолчанию не показывает; после правок ядро пересчитывает родителей всех строк `detail`; +- `missing` — строка без кода с содержимым, над которой во всём документе нет строки `ok` («Выручка» aep_3, где 2110 + уехал в ячейку значения); строки роли `title` сюда не попадают. + +Строка без кода и без содержимого (заголовок раздела «АКТИВ», «в том числе:» без сумм) — не строка. **Счётчиков +отброшенных строк нет — есть статусы** (отменяет «строк с нечисловым кодом пропущено», «строк без кода с суммами +пропущено», OQ-36 / OQ-141; OQ-149 — восстановление строк по наименованию — остаётся открытым). Строка формы с пустой +ячейкой кода под другой строкой с кодом попадёт в `detail` и будет скрыта; её значения не войдут в сумму раздела, +нарушение §7 подсветит родителя (пустая цель при непустых членах — нарушение), аналитик раскроет расшифровки и назначит +код (Р-13) — осознанная цена Р-8. + +**Повтор кода** (Р-4) — законный случай (один код, разные наименования; договорённость ДБД в AURORA-1431, закрывает +AURORA-1406): все строки `ok`, порядок как в файле, ни предупреждения, ни `duplicate_line_codes` (отменяет «оставлена +первая строка», решение № 15 среза 3a и OQ-152). Для §7 и всего после — сумма по коду (агрегат формы по коду, §9.5 п. 2). +Неверно прочитанный код, совпавший с существующим, ловит структурная проверка (§11.9). + +**Разлепление склеенных строк** (OQ-156; Р-17 заменяет решение владельца 08.10.2026 о безусловном `needs_check`) — +**до** разбора кода: ячейка кода содержит N ≥ 2 частей через перевод строки, каждая непустая ячейка значений — ровно N +частей, и хотя бы одна часть кода разобралась как код диапазона столбца («44 30\nI |» aep_6 не множится: 4430 не в +диапазоне ББ). Получается N строк (`ref` `row:#`): k-й код (нечитаемая часть — строка `unreadable`), k-я часть +каждой ячейки значений, наименование — k-я из непустых частей, если их тоже N, иначе целиком; `line_code_text` и `text` — +k-е части; уверенность — исходной ячейки. Значения разлепленных строк — `is_recognized = false`, нуждаются в покрытии +§7 (при сошедшихся соотношениях не подсвечиваются); само разлепление — `ROW_SPLIT` (`info`). Число частей не совпало или +ни одна часть не код — одна строка, многострочное значение → `amount = null`, `string_value` = текст, +`is_recognized = false`. Пример: ББ РБ и Счастливочной за 1 кв. 2025 — код `1500\n1700`, суммы `917 775\n2 371 333`. +У Excel не срабатывает. + +### §11.5 Шаг 4 — значения ячеек + +Выход: `{column_index, amount, string_value, text, confidence, is_recognized}` (+ `needs_review`, §11.9). `text` — всегда +сырой текст источника; `string_value` — только когда `amount = null` и текст непуст и не прочерк. + +1. **`value` есть (Excel):** число → `amount` с квантованием до 2 знаков half-up, не больше 16 целых цифр (иначе `amount + = null`, `string_value` = текст, `is_recognized = false`, `VALUE_OUT_OF_RANGE` `info`); дата или логическое → `amount + = null`, `string_value` = текст, `is_recognized = false`. +2. **`value` нет — `parse_amount(text, source)`**, один для обоих, по стадиям (S-57): (а) прочерк (`-`, `–`, `—`, `−` + без цифр) и пусто → `amount = null`, `is_recognized = true` (OQ-148); (б) крайние `|` удаляются (до замены + двойников), затем двойники вплотную к цифре (§11.3); (в) **ядро числа** — от первого из «цифра, `(`, `-`, `−`, `+`» + до последнего из «цифра, `)`, `}`, `]`»; всё вне ядра — краевой мусор (`:`, `=`, `■`, `•`, кавычки, `{`, `[` …), + выбрасывается с `is_recognized = false` («: : 2856983 = ■» → 2856983, «| : 122477» → 122477); (г) в ядре допустимы + только цифры, пробелы (NBSP, тонкие), `,`, `.`, знак в начале и одна пара скобок вокруг всего числа (`(` первым, `)`, + `}` или `]` последним — `}` / `]` исправление, OQ-147: «(7744536}» → −7744536, `is_recognized = false`; `{` и `[` + открывающей скобкой не считаются); иначе — буква в ядре, скобка без пары или не на краю, знак не в начале, другой + символ между цифрами — `amount = null`, `string_value` = текст, `is_recognized = false` («211(1 |», «(Ю4)», «{38378)»); + (д) цифр нет — `amount = null`, `string_value` = текст. Разделители: + - `ocr`: запятая — тысячи, если весь текст (после `strip` и удаления пробелов, с учётом знака и пары скобок на всю + строку) — `[+-]?[1-9]\d{0,2}(,\d{3})+` (AURORA-1405, sg#35: `4,200` → 4200, `1,234,567` → 1234567); точка — тысячи + при `[+-]?[1-9]\d*(\.\d{3})+` (sg#37: `837.994` → 837994, `1.234.567` → 1234567, `1 234.567` → 1234567); удаление + таких разделителей исправлением не считается. Иначе запятая и точка — десятичный разделитель (`4,2` → 4.2, `1 234,56` + → 1234.56); смешанные записи (`1.234,56`, `1,234.56`) — цифры подряд, `is_recognized = false`. **Три и более знаков + после десятичного разделителя** (`0,125`, `12.3456`) — `amount = null`, `string_value` = текст (решение № 30 среза + 3a сохраняется для OCR-текста: такое значение не укладывается в `numeric(18,2)`); + - `excel`: запятая и точка — десятичный разделитель («4,200» = 4,20, «1.500» = 1,5; как прежний `to_money`). +3. **Пробел внутри числа** допустим только как разделитель тысяч (группы ровно по три цифры); иначе `amount` — «лучшая + догадка» (цифры подряд, не длиннее 16 цифр, иначе `null`), `is_recognized = false`, сырой текст — в `text` («42831 1» → + 428311; AURORA-1511, 1519). Многострочное значение в строке с однострочным кодом → `amount = null`, `string_value` = + текст, `is_recognized = false`. Excel-ячейки «число текстом» идут тем же путём. +4. **`is_recognized`** — один смысл у обоих: текст разобран как число без выброшенных и исправленных символов (замена + двойника, `}` / `]` вместо скобки — исправление). `confidence` — отдельно, от источника (у Excel — 100); прежнее «`is_recognized` + = `confidence == 100` и ничего не выброшено» шлюза отменено. +5. **Пустая ячейка** (Р-2): позиции нет, текст пуст или прочерк → `amount = null`, `string_value = null`, + `is_recognized = true`, `confidence` ячейки (позиции нет — 100); собственной подсветки нет. Непрочитанная ячейка ИС + (пустой текст, уверенность 0) нуждается в покрытии по §11.9. +6. **Единицы:** «тыс. руб.» и ≥ 10 % дробных значений → `FRACTIONAL_IN_THOUSANDS` (`info`, документ остаётся `done`; + OQ-138 — единицам шапки верим); то же в книге xlsb без числовых форматов — `XLSB_SCALE_SUSPECT` (`error`: вероятен + масштаб ×1000, а §7 сходится при любом масштабе). + +Разбор сумм ОСВ (`to_money` в `classify.py`, §1) этими правилами не затрагивается. + +### §11.6 Шаг 5 — код, наименование, редакция + +Строка: `{row_index, ref, line_status, line_code, line_code_text, line_code_confidence, line_name, line_name_confidence, +parent_ref, needs_review, values}`. `line_code` — разобранный код или `null` у `unreadable` / `missing` / `detail`; +`line_code_text` — сырой текст ячейки кода; `line_code_confidence` — от источника (у кода с заменой двойника — не выше 99), +**без срезания до 99 по справочнику**; `line_name` — распознанный текст наименования как есть, с уверенностью источника +(Р-3: имя из справочника шлюза `api/rules/statement_lines.yaml` отменено, файл удаляется; соответствие «код → +наименование» — только в ядре, §7 «Полный состав формы»). **Редакция** (`66n` / `fsbu4`) в сервисах нужна только для +упорядоченного перечня кодов (§11.9): проверяются **обе** редакции, берётся та, где подпоследовательность длиннее, а при +равенстве — по эффективной дате (`fsbu4` — после 2024-12-31, как `edition` ядра, OQ-145): в 2025 году формы сдают и на +бланке 66н (Межениновская, 3 кв. 2025), и выбор только по дате не подтвердил бы её код 1120 (в перечне `fsbu4` его нет). Прежний выбор редакции шлюза для имён (по дате, по кодам, «редакция не +определена» / «не заполнена» → `needs_check`) отменён. + +### §11.7 Шаг 6 — тип формы, вариант, документ + +1. **Тип** — большинство кодов строк `ok` в отрезке ББ → `BALANCE`, ОФР → `INCOME`, прочих форм и пояснений → + `DISCLOSURE`; совпало с `type_hint` или `type_hint = null` — тип по кодам; противоречит — тип по кодам и `TYPE_CONFLICT` + (`error`); кодов нет — `type_hint`. Поиск столбца кодов (шаг 2 п. 1) выполняется **всегда**, в том числе при + `type_hint = DISCLOSURE`: подпись «Пояснения» или имя листа «Расшифровка» над таблицей с кодами 1110–1700 не должны + превратить баланс в пустое пояснение — большинство кодов в отрезке ББ или ОФР даёт тип по кодам и `TYPE_CONFLICT` + (`error`). Без столбца кодов: `type_hint` есть → документ без строк (`DISCLOSURE` — `done`, иначе `needs_check` с + `CODE_COLUMN_NOT_FOUND` `error`); `type_hint` нет → у OCR таблица ИС с содержимым (хотя бы одна ячейка с цифрой + или «цифрой под видом буквы») — предупреждение **файла** `TABLE_LOST` (`error`, `locator = file:<имя json>;table:`), + таблица только из текста — `TABLE_SKIPPED` (`info`, файл, S-71); у Excel — лист «прочее», предупреждение **файла** + `SHEET_OTHER` (`info`, `locator = sheet:<имя>`); документа во всех случаях нет. `DISCLOSURE` (по `type_hint` без + кодов форм или по кодам прочих форм) — шаги 3–7 не выполняются, `columns` и `lines` — пустые массивы (ядро строки + пояснений не хранит), `checks` нет, документ `done`. +2. **Вариант формы** `form_variant` — только по структуре кодов всех строк `ok` (ядро определяет его заново при каждом + пересчёте после правки; ОКУД 0710096 — код КНД комплекта, на бланках + 0710001 / 0710002, признаком не служит): ББ — есть 1600 и нет ни 1100, ни 1200 → `simplified`; ОФР — есть 2400 или + 2110 и нет ни 2100, ни 2200, ни 2300 → `simplified`; иначе `full`; у `DISCLOSURE` / `OTHER` — `null`. Шаг 7 и перечень + кодов берут набор варианта. Образец упрощённой формы — в fixtures при первой возможности (OQ-166). +3. **`locator`** — пары `ключ:значение` через `;`: `file:<имя json>;table:` / `sheet:[,]`. Шапка документа — + поля кандидата (`text` → `<поле>_text`, разобранное → поле, `confidence` → `<поле>_confidence`). + +### §11.8 Шаг 7 — самопроверки §7 + +Правила счёта — §7 («Самопроверка в сервисах»): один словарь у обоих сервисов, значение кода — сумма строк `ok`, +«посчитанное» соотношение, допуск и округление, `abs_terms` подтверждают величину, но не знак, полный или упрощённый +набор по `form_variant`, родитель подстроки. `checks` есть у каждого `BALANCE` / `INCOME` со строками и отсутствует у +документа без строк. Проверки по словарю имён и перечню кодов («код не в словаре», «код не из редакции») — в ядре, +`info` к строке. + +### §11.9 Шаг 8 — `needs_review`, статус, предупреждения + +**Подтверждение проверкой** (Р-5, Р-7, Р-11, Р-17, Р-21). Проверка §7 — арбитр, уверенность — подсказка там, где +проверки нет. Правила ниже — для значений строк `ok`, `unreadable`, `missing`; у значений строк `detail` `needs_review = +false` всегда (Р-23, решение владельца 18.10.2026): кода у них нет, покрыть их нельзя, в §7, агрегаты и статус они не +входят, сомнение видно в подсказке ячейки (`text`, уверенность). + +- Значение **нуждается в покрытии**, если `confidence < 100` (Р-5: у ИС уверенность дискретная — 100, ~80, 71, 67, 60, + 50, 25, 0; в группе 70–89 есть настоящие ошибки, например «11 30» с уверенностью 80) или `is_recognized = false` + (грязный текст, замена двойников, разлепленная строка). Чистые значения в покрытии не нуждаются. +- Значение **покрыто**, если `amount` значения не `null` и в его колонке есть соотношение с участием его кода, которое + посчитано и сошлось: **жёсткое** — в пределах допуска ±1 или округления 0,001 %; **мягкое** — только точно, в пределах + допуска ±1, без «округления» (Р-21, решение владельца 18.10.2026). Мягким покрываются строки, у которых жёстких + соотношений нет: 2400, 2410, 2411, 2412, 2460 (ОФР полного набора; `2400` упрощённого — тоже мягкое, S-5), 2510, 2520, + 2530, 2500 (`2500 = 2400 + 2510 + 2520 + 2530`), пятизначные подстроки («сумма подстрок = родитель»). +- **Знак** члена `abs_terms`, нуждающегося в покрытии: подтверждён, если форма знака совпадает с формой строгого + большинства **остальных** членов `abs_terms` той же колонки с непустым `amount`, `is_recognized = true` и без правки + аналитика (сама ячейка не голосует); форма знака — по результату разбора `parse_amount`: «скобки» (пара, в том числе с + `}` / `]`), «минус», «без знака»; голосующих нет или голоса поровну — не подтверждён (S-66; то же правило — при + покрытии мягким соотношением, например 2410 в `2400 = 2300 − 2410 + 2420`). «(7744536}» подтверждается, если 2210 и + 2220 той же колонки в скобках; сомнительное «38378» без знака при остальных в скобках — подсвечивается; «{38378)» + разбором даёт `null` (§11.5) и подсвечивается как непокрытое. +- `needs_review = true`, если значение нуждается в покрытии и не покрыто или его знак не подтверждён; а также у всех + ячеек жёсткого соотношения, не сошедшегося вне допуска и округления, независимо от уверенности. Иначе `false`: сырой + текст и уверенность остаются в подсказке ячейки. Мягкое соотношение, не сошедшееся точно, подсветки не даёт. + +Не покрыто проверками и остаётся на уверенности: строки 29xx (прибыль на акцию), 2420 и 2530 вне сошедшихся мягких +соотношений, детализация без строки-родителя, `DISCLOSURE`, колонки, где код не участвует ни в одном посчитанном +соотношении. Риск двух взаимно компенсирующих ошибок в одном соотношении принимается. Закрывает AURORA-1524 без ущерба +для 1519. + +**Код строки** (Р-5, Р-12, Р-16, Р-20). Код неуверенный — `line_code_confidence < 100` или заменён двойник; структурная +проверка — **только для неуверенных кодов** (у Excel и чистых OCR-кодов расхождение с перечнем — `info` ядра +`CODE_NOT_IN_EDITION`, не `needs_check`: пятизначные подстроки 1С и нестандартные коды клиента не блокируют +автоподтверждение). В пакете — **упорядоченный перечень кодов** каждой формы по редакциям (66н, ФСБУ 4) и вариантам, без +наименований, в порядке бланка (итоги после слагаемых: 1110 … 1190, 1100; 2110, 2120, 2100). **Данные перечня — +`contracts/statement-code-orders.yaml`** (источник; в пакете — его байтовая копия, сверяет `sync-statements --check`): +объединение кодов шаблонов банка (словарь ядра) и кодов печатных форм 1С и сканов из `fixtures/` (1105, 1130, 1140, 1215, +1231, 1530, 2420, 2460, 2530, 2930) с источником у каждого; код без образца в перечень не входит (кандидаты — OQ-168; +временное правило утверждено владельцем 18.10.2026, Р-26: неуверенный такой код на скане — `needs_review` строки), +упрощённые варианты — по бланку (OQ-166). **Законные повторы кода** (Р-20, решение владельца 18.10.2026) — часть перечня +(`repeats`): у `fsbu4` — 1220 «НДС, начисленный по отгрузке», 1520 «Обязательство по аренде», 2410 «Налог при упрощённой +системе налогообложения» (печатная форма 1С, AURORA-1431), у `66n` — раздел III некоммерческой организации после раздела +III (aep_3). Алгоритм: четырёхзначные коды строк `ok` документа — последовательность (коды, которых нет в перечне +редакции, в её последовательность не входят); для **каждой** редакции перечня (66н и ФСБУ 4 — всегда обе: Межениновская +за 3 кв. 2025 сдана на бланке 66н) — **лучшая** подпоследовательность, согласованная с порядком перечня варианта формы +документа (повтор допустим столько раз, сколько код стоит в перечне): сначала наибольшее число уверенных кодов (якоря; +противоречащие по порядку якоря все войти не могут — входит наибольшее согласованное множество), затем наибольшая +длина; редакция — с лучшей (при равенстве — по эффективной дате: `fsbu4` после 2024-12-31, OQ-145); неуверенный код +подтверждён, только если входит во все лучшие подпоследовательности выбранной редакции (S-65). Вариант формы — по всем +строкам `ok` (§11.7); ядро пересчитывает вариант и проверку после правки кода. Пятизначный код +подтверждён, если подтверждён его родитель (§7) и подстрока стоит после него. Повтор кода, которого **нет в перечне** +(или копий больше, чем в перечне), у которого хотя бы одна копия неуверенна, — `needs_review` у неуверенной копии (Р-16); +повтор из перечня подтверждается структурой, как любой код. Неподтверждённый неуверенный код — `needs_review` строки, +документ `needs_check`. Подмену неуверенного кода на незапечатанного соседа того же раздела (1110 → 1120) структура не +ловит — это закрывает ядро: для строк с неуверенным кодом условие `RECOGNIZED` включает совпадение распознанного +наименования со справочным (§9.3). + +**Совпадение наименований** (ядро; S-4 с оговорками ревью, решение владельца 18.10.2026): обе строки — нижний регистр, +`ё` → `е`, всё кроме букв и цифр → пробел, пробелы схлопываются, крайние обрезаются (`strip`); совпало, если строки +равны, или более короткая (≥ 5 символов) входит в более длинную, или `difflib.SequenceMatcher` ≥ 0,8 — с **любым** +справочным именем кода в любой редакции словаря ядра (у кода может быть несколько имён: синонимы и законные повторы — +Р-20). Исключение: если хотя бы одно из сравниваемых имён начинается с «итого по разделу», сравниваются **номера +раздела** — начальные буквы `i` / `v` / `x` первого слова после фразы (кириллические «і», «х» приводятся к латинским, +хвост слова отбрасывается: «Итого по разделу V1» Счастливочной и «Итого по разделу V /» РБ → V); совпало ⇔ номера равны; +номер не выделен — только равенство строк (S-60). Без исключения «итого по разделу i» входило бы в «итого по разделу ii» +— подмена 1100 ↔ 1200 ↔ 1300 не ловилась бы именем. В словарь имён ядра `fsbu4` в PR перехода добавляются вторые имена +законных повторов 1220, 1520, 2410 и коды 1105 «Гудвил», 1130, 1140, 1530, 2460 (спека `arm-core`). +Кода нет в словаре или наименование пусто — не совпало; несовпадение — `NAME_MISMATCH` (`info`) у строки. По +`fixtures/ocr` коды часто приходят с уверенностью 50 и 25 — требовать 100 нельзя. + +**Поля шапки.** Дата отчёта с уверенностью ниже 100 подтверждена, если совпала с самой поздней абсолютной датой колонок +с подписью уверенности 100 (неуверенная подпись дату отчёта не подтверждает, S-64); +иначе `report_date` попадает в `needs_review_fields` и `REPORT_DATE_UNCERTAIN` (`error`) — неверно прочитанный год давал +бы неверный период и автоподтверждение. **У бланка 66н только с относительными подписями** («На отчётную дату», …) +абсолютных дат колонок нет, и неуверенная дата отчёта структурой не подтверждается никогда — такой документ всегда +`needs_check` с `REPORT_DATE_UNCERTAIN`; аналитик сверяет дату с бланком и подтверждает её (PATCH `header.report_date` +тем же значением или исправленным, §9.5 п. 9). ИНН и организация с уверенностью ниже 100 — `info` (их сверяет ядро: ИНН +— `MISMATCH` с ЮЛ, организация — по алиасам), в `needs_review_fields` не попадают (`needs_review_fields` — поля, которые +держат документ в `needs_check`; подсветка ИНН и организации на экране — по предупреждениям с `field`; S-10, решение +владельца 18.10.2026); единицы и прочие поля на статус не влияют. + +**Предупреждение** — структура `{code, severity, message, line_refs?, column_index?, field?}`; уровень файла +(`metrics.warnings`) — ещё `locator?`. Место — `locator` документа (у предупреждения файла — его `locator`), не текст. +`line_refs` — только `ref` строк из `lines[]` (о строках ролей `title` / `header` / `numbering` — в `message`); +`column_index` — номер столбца сетки (§11.3 п. 8). Принцип: `error` — только то, что меняет данные или их полноту; +`error` → `needs_check`, `info` на статус не влияет. Предупреждение уровня файла с `error` блокирует `RECOGNIZED` всех +документов файла (§9.3). + +| Код | Уровень | Когда | Чем снимается в ядре | +|---|---|---|---| +| `JSON_FAILED` | error, файл | json набора ИС ошибочен при успешном соседе | перезагрузка | +| `TABLE_PARSE_ERROR` | error, файл | исключение при разборе таблицы ИС (`locator` таблицы; документа нет) | перезагрузка | +| `TABLE_LOST` | error, файл | таблица ИС с содержимым (ячейка с цифрой или «цифрой под видом буквы») без столбца кодов и без типа | перезагрузка | +| `TABLE_SKIPPED` | info, файл | таблица ИС только из текста без столбца кодов и без типа (документа нет) | — | +| `CODE_COLUMN_NOT_FOUND` | error | форма `BALANCE` / `INCOME` без столбца кодов | перезагрузка | +| `SEGMENT_WITHOUT_CODES` | error | сегмент с содержимым без столбца кодов (в т. ч. лист-продолжение) | перезагрузка | +| `SHEET_NOT_JOINED` | error | лист с ≥ 3 кодами и другим числом колонок не присоединился | перезагрузка | +| `CONTINUATION_TYPE_CONFLICT` | error | тип маркера продолжения противоречит форме | перезагрузка | +| `SEGMENT_COLUMNS_MISMATCH` | error | при сопоставлении по порядку число колонок сегмента не совпало с первым | перезагрузка | +| `COLUMN_WITHOUT_DATE` | error | числовая колонка без разобранной подписи | PATCH `columns[].period_date` | +| `COLUMN_DATE_UNCERTAIN` | error | неуверенная подпись не попала в ожидаемые даты | PATCH `columns[].period_date` (в том числе той же датой — подтверждение) | +| `HEADER_ROW_NOT_FOUND` | error | заголовка нет ни у одного сегмента | перезагрузка | +| `TYPE_CONFLICT` | error | тип по кодам противоречит `type_hint` | перезагрузка | +| `NO_ORGANIZATION_NO_INN` | error | нет ни организации, ни ИНН | PATCH `header.inn` / `organization_name` | +| `PERIOD_UNKNOWN` | error | нет ни даты отчёта, ни абсолютных дат колонок | PATCH `header.report_date` | +| `REPORT_DATE_UNCERTAIN` | error | неуверенная дата отчёта не совпала с датами колонок (у бланка только с относительными подписями — всегда) | PATCH `header.report_date` (в том числе тем же значением — подтверждение) | +| `XLSB_SCALE_SUSPECT` | error | xlsb без форматов, «тыс. руб.», ≥ 10 % дробных | перезагрузка | +| `INN_NOT_FOUND`, `INN_UNCERTAIN` | info | ИНН не найден / неуверен при найденной организации | — | +| `ORGANIZATION_NOT_FOUND`, `ORGANIZATION_UNCERTAIN` | info | организация не найдена / неуверенна при найденном ИНН | — | +| `REPORT_DATE_UNPARSED` | info | дата отчёта не разобрана при наличии дат колонок | PATCH `header.report_date` | +| `UNITS_NOT_FOUND` | info | единицы не найдены | — | +| `FILE_TYPE_MISMATCH` | info, файл | формат файла не совпал с `file_type` | — | +| `XLS_NO_CODEPAGE` | info, файл | книга xls без кодовой страницы — кириллица могла прочитаться неверно (формы и ОСВ книги) | — | +| `COLUMN_SKIPPED` | info | подпись из словаря исключений пропущена | — | +| `FRACTIONAL_IN_THOUSANDS` | info | дробные при «тыс. руб.» (кроме xlsb без форматов) | — | +| `XLSB_NO_FORMATS`, `NUMBER_FORMAT_UNPARSED` | info | числовые форматы недоступны или не разобраны | — | +| `VALUE_OUT_OF_RANGE` | info | число Excel длиннее 16 целых цифр | — | +| `SHEET_OTHER` | info, файл | лист Excel «прочее» без типа и кодов (`locator = sheet:<имя>`; документа нет) | — | +| `ROW_SPLIT` | info | разлеплена склеенная строка | — | +| `OSV_*` | info | предупреждения листа ОСВ (§1) | — | +| `CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH`, `STATEMENTS_VERSION_MISMATCH` | info, ядро | проверки ядра (§9.3, §9.5) | пересчёт | +| `RESULT_STATUS_INCONSISTENT` | info, ядро | `document.status = done` из результата противоречит строкам, `needs_review`, нарушениям или предупреждениям документа — ядро считает его `needs_check` (§9.3) | пересчёт после правки | +| `MAPPING_WARNING` | info, ядро | предупреждение маппера 1.x по документу (повтор кода, значение вне колонок) | — | +| `LEGACY_WARNING` | info, файл | строка `metrics.warnings` результата 1.x (`recognition_job.metrics`) | — | +| `RELATION_FAILED` / `RELATION_SOFT` | error / info | код записи `document_issue` вида `relation` / `soft` — нарушение §7 из `checks` (или пересчёта ядра) с `codes`, `relation`, `expected`, `actual` | пересчёт после правки; `relation` — ещё акцепт | + +Назначение кода и отметка `detail` снимают нарушения, пересчитываемые §7. Новый код предупреждения вводится словарём +пакета и строкой этой таблицы; в контракте `code` — строка, не перечисление. + +**Статус документа** — одна функция общего кода: `done` ⇔ раскладка найдена ∧ (организация или ИНН) ∧ период определён +(дата отчёта или самая поздняя абсолютная дата колонок) ∧ все строки `ok` или `detail` ∧ нет значений, строк и полей +шапки с `needs_review` (значения строк `detail` не учитываются, Р-23) ∧ нет жёстких нарушений §7 вне допуска и +округления ∧ нет предупреждений документа уровня `error`; иначе `needs_check`. `DISCLOSURE` — `done` без условий о +раскладке, строках и проверках. Квартал периода, +сверка ИНН и наименований — знание ядра (§9.3), в `done` не входят. У ОСВ `document.status = done`, когда контрольные +суммы §1.4 листа сошлись (§1.6, S-22), её предупреждения — `info`. + +**Интерфейс функции статуса** (K-14) — один для сервиса и ядра, на входе только данные, без типов источника: +`document_type`; `layout_found` (сервис — столбец кодов найден хотя бы у одного сегмента; ядро — у формы есть хотя бы +одна строка из источника, `row_index` не `null`); `has_organization_or_inn`; `period_known` (дата отчёта или абсолютная +дата хотя бы одной колонки — у ядра `document.reporting_date` не `null`); строки `{line_status, needs_review}`; значения +`{line_status строки, needs_review}` (значения строк `detail` функция не учитывает); число жёстких нарушений вне допуска и округления; `accepted` (акцепт — жёсткие нарушения не +учитываются); уровни предупреждений документа (у ядра — записи `document_issue` вида `warning` с `severity = error`). +`needs_review` строк и значений на входе уже учитывает подтверждения: у ядра правленое (`is_modified`, +`line_code_modified`) или подтверждённое (`is_confirmed`) — `false`. Сервис собирает вход из `FormCandidate` и +результата шагов 2–7, ядро — из строк БД (после правки — точкой входа пакета для пересчёта по сохранённым данным, +спека `statement-normalization`; при приёме результата — для проверки согласованности `done`, §9.3); так один код даёт +один статус. + +**Ожидание на реальных сканах** (`fixtures/ocr`, оценки ревью спеки 18.10.2026, K-4 и L-1): после Р-20 (повторы 1220 / +1520 / 2410 в перечне `fsbu4` и синонимы в словаре имён), Р-21 (мягкие соотношения ОФР) и Р-23 (значения `detail` не +держат статус) законные повторы, строки 24xx / 25xx и ячейки расшифровок документ не держат. По прогону ревьюера ББ и +ОФР РБ («Бухгалтерская отчетность 1 кв.25») и Счастливочной («БО 1 кв 2025») могут стать `done`: все их неуверенные +значения строк с кодом покрыты сошедшимися соотношениями, неуверенные коды подтверждаются перечнем `fsbu4`, а держали +их только ячейки расшифровок. `needs_check` остаётся там, где есть что смотреть: строки `unreadable` / `missing` с +суммами (aep_3), несошедшиеся соотношения и мусор в значениях (aep_6: «(Ю4)» в 2350), неуверенная дата отчёта. +Golden-эталоны `fixtures/ocr/expected_normalize/` пересчитываются в PR scan-gateway (шаг 5 выкладки), фактическая доля +`done` — в его описании. + +**Статус конверта:** `done` ⇔ все документы `done` ∧ нет предупреждений файла уровня `error`; иначе `needs_check`; +документов нет — `error` (`RECOGNITION_FAILED` у scan-gateway, `INVALID_DOCUMENT` у tabular-parser — коды описывают +разные причины). Порядок документов tabular-parser — сначала ОСВ в порядке листов, затем формы. Статус конверта в +условие `RECOGNIZED` ядра не входит (Р-19). + +### §11.10 Что отменено и заменено этим параграфом + +| Прежнее правило | Теперь | +|---|---| +| Строки без кода не отдаются и не хранятся (OQ-36, OQ-141; §12 п. 5) | статусы `detail` / `missing` / `unreadable`, хранятся (Р-8); OQ-36 и OQ-141 закрыты | +| Двойники — только в датах (§11.1 п. 1, решение № 22 среза 3a) | двойники в датах, подписях, кодах и суммах (Р-11); ИНН — без двойников | +| Три и более знаков после разделителя → `amount = null` (решение № 30 среза 3a) | сохраняется для OCR-текста; для числа Excel — квантование до 2 знаков | +| Разлепленная строка → документ `needs_check` (решение владельца 08.10.2026 по OQ-156) | значения нуждаются в покрытии §7, `ROW_SPLIT` — `info` (Р-17) | +| Повтор кода: остаётся первая строка (решение № 15 среза 3a, §12 п. 5, OQ-152) | все строки `ok`, сумма по коду (Р-4) | +| Наименование — из справочника шлюза, уверенность кода ≤ 99 вне справочника (§11.1 п. 6, п. 10) | наименование из источника, уверенность от источника (Р-3) | +| Без шапки — синтез шапки и `needs_check` (рулинг 06.10.2026) | правило строки заголовка §11.3 п. 4 | +| Подпись без периода не читается (§12 п. 4) | колонка без даты и `error` (Р-6) | +| Закрывающая `}` / `]` не даёт минус (решение № 12, OQ-147) | `}` / `]` — закрывающая скобка, `is_recognized = false` | +| Длинное тире → `is_recognized = false` (решение № 20, OQ-148) | прочерк любого вида — `null`, `is_recognized = true` (OQ-148 закрыт решением владельца 18.10.2026, Р-22 / S-7) | +| `is_recognized` OCR = уверенность 100 ∧ ничего не выброшено; любое предупреждение → `needs_check` | `is_recognized` — только разбор; статус — §11.9 | +| `checks` ядро игнорирует и пересчитывает §7 при загрузке | хранит как пришли, пересчитывает только после правок | + +### §11.11 Отложено + +Восстановление строк по наименованию (OQ-149); сопоставление формы без столбца кодов по именам строк (OQ-139); склейка +страниц из разных файлов (OQ-140) и половин формы из одного файла (OQ-165, Р-15); приведение единиц сверх числового +формата (OQ-138); формулы §7 по редакциям и знаки 2410/2420 (OQ-12, OQ-146); образец упрощённой формы (OQ-166); коды +перечня без образца в fixtures (OQ-168); +несколько периодов в одном файле ИС (OQ-167, допущение владельца); переключатель «как в файле» на экране (Р-14). + +Что это даёт ядру: один маппинг `statementDocument → document + financial_statement_* + document_issue` для PDF и Excel; +экран «Проверка распознавания» показывает статусы строк и предупреждения у места. --- -## §12. ББ и ОФР из Excel (tabular-parser, второй тип документа) +## §12. ББ и ОФР из Excel — см. §11 -Источник: ТЗ 6.2 («парсинг ОСВ, бухгалтерского баланса и ОФР (эксель файлов)»), DOC-037734, DOC-221281; формат — -Приказ 66н / ФСБУ 4/2023, но на практике это и выгрузка из 1С («Регламентированный отчёт»), и шаблон банка -(`Копия Рекомендуемые формы расшифровок…`, лист `Отчетность` в `Анализ *.xlsx`), и вручную набитая таблица. - -Принцип тот же, что в §1.0: **структура ищется по содержимому**, результат — тот же `statementDocument` контракта, -что и у `scan-gateway`, чтобы ядро маппило ББ/ОФР одинаково независимо от источника (`confidence = 100`, -`is_recognized = true` для табличного пути). - -**Статус: реализовано (Срез 2b, tabular-parser feat/14, контракт v1.2; решения владельца 06.10.2026).** Модули — -`api/src/parser/statements/`, словари — `api/rules/statement_headers.yaml`, `statement_periods.yaml`, -`statement_checks.yaml`; ожидания по реальным файлам — `api/tests/statements_manifest.yaml` (06-test-data §4б). - -1. **Классификация листов**: лист ОСВ → форма → продолжение → прочее. Форма: заголовок («Бухгалтерский баланс», - «Отчет о финансовых результатах», «Отчет о прибылях и убытках») или ОКУД в **любой** строке до таблицы (у НПЗ он стоит - под титулом); титул «Бухгалтерская отчетность» — не тип, тип берёт ближайший следующий заголовок. Тип по ОКУД: - 0710001 → `BALANCE`, 0710002 → `INCOME`, прочие 0710xxx (0710003 капитал, 0710004 ДДС) и пояснения («Пояснения к - бухгалтерскому балансу», «Расшифровка») → `DISCLOSURE`; при противоречии ОКУД и заголовка — тип по ОКУД и - предупреждение. Две формы на одном листе — по второму заголовку, в книге несколько листов — несколько `document`. - Продолжение: лист без заголовка формы, но с маркером «Форма … с.2», либо с подписью «Код» и периодами правее, либо с - колонкой кодов. ОСВ и формы в одной книге допустимы. -2. **Колонка кодов**: в блоке ≥ 60 % непустых значений — коды (ББ 1100–1799, ОФР 2100–2999, расшифровки 5xxx, - пятизначные — по первым четырём цифрам), кодов не меньше `min_codes` = 3; заголовок «Код/Коды» необязателен, числа вида - `1110.0` читаются как текст «1110». Колонки нет — документ без строк, `needs_check`, предупреждение «колонка кодов не - найдена»; сопоставление по наименованиям строк **не делается** (решение владельца, OQ-139). -3. **Колонка наименований** — ближайшая левее кодов с преобладанием текста; «Пояснения» (пустые или ≤ 3 символов) не - наименование. -4. **Колонки-периоды**: ячейки заголовка правее кодов с распознанным периодом («На 31 декабря 2024 г.», «31.12.2024», - «За 9 месяцев 2025 г.», «За январь - сентябрь 2025 г.», «2024»), по порядку слева направо (`column_index` 0, 1, 2…); - `period_date` — дата или конец периода; `is_current` в контракте нет — ядро берёт самую позднюю. Заголовок в две строки - («На» + «20__ г.», перенос внутри ячейки) склеивается; строка нумерации «1 2 3 4 5» пропускается. Колонки без периода - («Изменение», «%») **не читаются** (одно предупреждение на документ): у `statementDocument` поля `extra` нет. -5. **Значения**: `Decimal`, скобки → минус, «–»/пусто → `null`, нечисловой текст → `string_value`, - `is_recognized = false`. **Значение из Excel — как его показывает числовой формат ячейки** (OQ-138, пересмотрено - владельцем 15.10.2026, AURORA-1528): выгрузки 1С хранят рубли с копейками, а в тысячи их переводит формат. Применяются - масштабирующие запятые (`#,##0,` → тысячи, `0,` → тысячи), число знаков после точки (не больше 2 — `numeric(18,2)`), - секции и условия (`[=0]"-"`, `[=-646532]"(647)"`); литерал условной секции разбирается как текстовая ячейка (скобки → - минус, пробелы/NBSP — разделители, «-»/«–»/«—»/пусто → `null`); округление «половина от нуля» (как в Excel); знак — от - сырого значения (для числовых секций; у секций-литералов значение берётся из литерала). Условия `[=x]` и значение - сравниваются с точностью 15 значащих цифр, как в Excel. Секция выбирается по порядку: при явных условиях ни одна не - подошла — формат не разобран, неявные условия у нефинальных секций — по умолчанию по числу секций. `General`/пустой - формат — значение как в ячейке. Формат, который не удалось разобрать (`%`, экспонента, дробь, даты, `@`, неизвестные - знаки вне кавычек вроде `₽`, больше четырёх секций, условие не по шаблону вроде `[=1E-5]`, литерал не число/пусто/ - прочерк, результат вне `numeric(18,2)`), — значение как в ячейке и одно предупреждение на документ с перечнем форматов. xlsb: стили не читаются - — значения как в файле и одно предупреждение на документ «формат чисел xlsb не читается: значения как в файле». ОСВ - правило не затрагивает. Единицы сверх формата не нормализуются: `header.units` — текст шапки; при «тыс.» и ≥ 10 % - отображаемых значений с дробной частью — предупреждение «значений с дробной частью при единице тыс. руб.: N из M» - (OQ-138). Строки без кода (разделы, «в том числе:», «Реализация иного имущества…» у НПЗ) не отдаются - (`ASSUMPTION(OQ-36)`, OQ-141); повтор кода — остаётся первая строка, код в `checks.duplicate_line_codes`, `needs_check`. - Пятизначные подстроки (11101, 12301…) отдаются как обычные строки. -6. **Шапка**: организация (после метки «Организация», иначе текст с признаком ОПФ строкой выше метки — НПЗ), ИНН 10/12 - цифр у метки «ИНН» (**ИНН аудитора не берётся**: строки после метки «аудитор» исключены), ОКПО, ОКОПФ/ОКФС, ОКЕИ, адрес, - единицы, `report_date_text`/`report_date` — из подзаголовка формы или тройки «число | месяц | год» у метки «Дата» (для - ОФР — конец периода). Шапка другого листа не используется: у ОФР НПЗ нет метки «ИНН» → `inn = null`. Найденное поле — - `*_confidence = 100`, не найденное — `null`. -7. **Самопроверка**: соотношения §7 по каждой колонке — данные `statement_checks.yaml`. **Жёсткие** (нарушение → - `needs_check`): ББ `1100`, `1200` (+1215), `1300`, `1400`, `1500`, `1600 = 1100 + 1200`, `1700 = 1300 + 1400 + 1500`, - `1600 = 1700`; ОФР `2100`, `2200`, `2300`. **Мягкие** (только предупреждение, `ASSUMPTION(OQ-12)`): `2400 = 2300 − - 2410 + 2420` и варианты ФСБУ 4/2023, `2500`, «сумма пятизначных подстрок = родитель» (по колонке — только если хоть одна подстрока в ней непуста; `null` ≠ 0, ноль — значение; AURORA-1527). Допуск ±1 **в единицах формы**, - отсутствующий код = 0, соотношение проверяется, только если присутствует его цель; правая часть считается дважды — как - записано и с членами `abs_terms` по модулю (1С выгружает расходы со знаком минус, 66н — в скобках). Результат — - `documents[].checks` (`relations_failed`, `soft_relations_failed`, `duplicate_line_codes`) и предупреждения. - Расхождение жёсткого соотношения больше ±1, но не больше 0,001 % от наибольшего по модулю числа соотношения — **округление итогов** (OQ-138, решение владельца 06.10.2026: единицам шапки верим): уходит в `soft_relations_failed` с предупреждением «сошлось в пределах округления», статус не меняется (`rounding_tolerance_percent` в словаре). Пример: книга НПЗ (`npz/2025-Q3`) — `1600 = 1700` расходится на 1.94 / 9.02 / 2.48 при итогах ~17 млрд, - ОФР Унагранде Компани (стр. 1) — итог 2100 округлён до тысяч, ОФР Унагранде Групп 3 кв. 2025 — 2200 = −1 365 000 как - малая разность слагаемых ~393 млн; все `done`. Пятизначная подстрока относится к ближайшей четырёхзначной строке выше с - теми же тремя первыми цифрами (12310 под 1230); то же правило ядро отдаёт экрану как `parent_code` (§7). ББ Унагранде (стр. 1) — только актив, `1600 = 1700` не сходится, - `needs_check`. -8. **Расшифровки** (0710003/0710004, пояснения, лист «Примечания» шаблона) — `DISCLOSURE` с `locator` и шапкой, если - нашлась, без `columns`/`lines`; на статус не влияет (OQ-23). Книга только из таких листов — `done`. -9. **Склейка**: листы-продолжения внутри книги присоединяются к последнему открытому документу формы, если число колонок - сумм совпадает; строки добавляются в конец, `locator` перечисляет листы через запятую. **Страницы в разных файлах не - склеиваются** (1С печатает форму постранично, один файл = одна страница): страница 2 без первой — документ с пустой шапкой - и `needs_check`, предупреждение «страница-продолжение без шапки: загрузите форму одним файлом» (решение владельца, OQ-140). - Продолжение без колонки кодов при непустом результате не теряется молча — `needs_check`. -10. **Статус**: документ `ok` ⇔ жёсткие соотношения сошлись по всем колонкам ∧ нет дубликатов кодов ∧ найдена колонка кодов - ∧ есть колонка-период ∧ в шапке есть организация или ИНН ∧ документ не одинокое продолжение. Книга — `done`, если `ok` - у всех документов, иначе `needs_check`; книга без листов ОСВ и форм — `INVALID_DOCUMENT`. Порядок документов: сначала - ОСВ в порядке листов, затем формы. Шаблоны (коды есть, сумм нет, шапки нет) — `needs_check`. -11. **Отложено**: запасной путь по именам строк (OQ-139), приведение единиц сверх числового формата ячейки (OQ-138), признак продолжения в контракте и - склейка страниц из разных файлов в ядре (OQ-140, Срез 3), строки без кода (OQ-141), образцы ФСБУ (OQ-142), лист - продолжения с единственной кодовой строкой (OQ-143 реализовано: после листа формы хватает одного кода, - `min_codes_continuation` = 1; первая страница по-прежнему требует `min_codes` = 3). - -Что это даёт ядру: один маппинг `statementDocument → document + financial_statement_*` для PDF и Excel (маппинг форм — -Срез 3); экран «Проверка распознавания» для Excel-источника показывает не страницы, а таблицу — UI-решение вне парсера. +Правила разбора форм из Excel (tabular-parser) с контракта v2.0 — часть общей цепочки нормализации ББ/ОФР: источник +Excel — §11.2, раскладка — §11.3, строки — §11.4, значения и числовой формат ячейки — §11.2 п. 3 и §11.5, тип и вариант — +§11.7, самопроверка — §7 и §11.8, статус — §11.9. Прежний текст §12 (Срез 2b, контракт v1.2) — в истории git, коммит +`43b4b80`. Ссылки «§12 п. N» в старых документах: п. 1 → §11.2 п. 1; п. 2 → §11.3 п. 1; п. 3 → §11.3 п. 7; п. 4 → §11.3 +п. 3, п. 6; п. 5 → §11.2 п. 3, §11.4, §11.5; п. 6 → §11.2 п. 2; п. 7 → §7, §11.8; п. 8 → §11.7 п. 1; п. 9 → §11.2 п. 4; +п. 10 → §11.9; п. 11 → §11.11. diff --git a/context/07-open-questions.md b/context/07-open-questions.md index a8303dd..b770b6e 100644 --- a/context/07-open-questions.md +++ b/context/07-open-questions.md @@ -38,10 +38,10 @@ | OQ-30 | Макет: шахматка и КФО показаны в тыс. руб. с 2 знаками, ОСВ — в рублях; где и как округлять (связано с OQ-26) | макет [30] [31-01] | срез 5–6 | допущение: хранить и считать в рублях, переключатель единиц — только отображение | | OQ-31 | КПП ЮЛ: в макете колонка и поле форм (`50`, `61`, `51-02`, `79`), в DBML v5.13 у `legal_entity` поля нет. Нужен ли КПП (и в импорте, и для ИП, у которых его нет)? | макет [50] [51-02] | срез 1, DBML v5.14; пересмотр — DBML v5.17 (docs#53, arm-core#45) | **закрыт решением владельца 07.10.2026: КПП нужен** (пересмотр; повод — макет и комментарий фронтенда «в ручке получения ЮЛ не приходит КПП»): DBML v5.17 — `legal_entity.kpp`, `legal_entity_history.kpp` (nullable). Формат — 9 символов: 4 цифры, 2 символа (цифра или латинская A–Z), 3 цифры, иначе `INVALID_KPP`; у ИП КПП запрещён (422 `INVALID_KPP`, при импорте строка пропускается с предупреждением); для ЮЛ необязателен (старые записи и импорт без колонки валидны); уникален только `inn` (КПП — атрибут, сопоставление контрагентов ОСВ по ИНН не меняется); изменение КПП — обычный PATCH со снимком в `legal_entity_history`. *История: 04.10.2026 — «КПП не нужен» (DBML v5.14 без поля; импорт его не читал; формы — без КПП); отменено 07.10.2026* | | OQ-32 | ЮЛ в макете состоит одновременно в ГСК и ГСЗ («Входит в ГСК» и «Входит в ГСЗ» заполнены, [79]); ТЗ 6.1 / ANL-030 — только в одной. Что верно? | макет [79] [51-02] | срез 1 (partial unique `related_group_member.legal_entity_id`) | **закрыт решением владельца 04.10.2026: ЮЛ только в одной ГК, одновременно — в одной ГСК и одной ГСЗ.** DBML v5.14: `related_group_member.kind` + partial unique `(legal_entity_id, kind) WHERE is_current` (отменяет ANL-030); выгрузка ЕЦП это подтверждает (флаги «входит в ГСК» и «входит в ГСЗ» одновременно) | -| OQ-33 | «ID ГСК/ГСЗ в CRM» в формах (`51-03`, `51-04`, `77-02`; пример «3-9CGSK01» в формате ЕЦП CRM): у `related_group` в DBML только внутренний `code` (GSK-001). Есть ли у ГСК/ГСЗ идентификатор в CRM? | макет [77-02] | срез 1, DBML | **закрыт реализацией Среза 1:** `related_group.code` генерируется системой (`GSK-NNNNNN`/`GSZ-NNNNNN`), ID из CRM не вводится и не импортируется (в выгрузке ЕЦП его нет — OQ-91). **Решение владельца 14.10.2026:** `POST`/`PATCH` ГСК/ГСЗ принимают `crm_id` и пока отбрасывают его (arm-core #111), колонка `related_group.crm_id` — отдельной задачей arm-core #112 (DBML v5.19) | +| OQ-33 | «ID ГСК/ГСЗ в CRM» в формах (`51-03`, `51-04`, `77-02`; пример «3-9CGSK01» в формате ЕЦП CRM): у `related_group` в DBML только внутренний `code` (GSK-001). Есть ли у ГСК/ГСЗ идентификатор в CRM? | макет [77-02] | срез 1, DBML | **закрыт реализацией Среза 1:** `related_group.code` генерируется системой (`GSK-NNNNNN`/`GSZ-NNNNNN`), ID из CRM не вводится и не импортируется (в выгрузке ЕЦП его нет — OQ-91). **Решение владельца 14.10.2026:** `POST`/`PATCH` ГСК/ГСЗ принимают `crm_id` и пока отбрасывают его (arm-core #111), колонка `related_group.crm_id` — отдельной задачей arm-core #112 (DBML v5.19). **18.10.2026, DBML v5.19:** колонка `related_group.crm_id` (`varchar`, nullable, уникальна, как у ГК и ЮЛ) введена в миграцию v5.19 (arm-core#112, Р-10 change `unified-statement-normalization`); `POST`/`PATCH` ГСК/ГСЗ сохраняют её, повтор — 409 `CRM_ID_EXISTS`; импорт ЕЦП её не заполняет (OQ-91). Вопрос закрыт до реализации arm-core#112 | | OQ-34 | «Удалить» ГСК в меню строки (`77-01`): физическое удаление или закрытие с историей? В DBML у `related_group` нет флага/истории удаления | макет [77-01] | срез 1 | **закрыт реализацией Среза 1 (ASSUMPTION подтверждено отсутствием возражений):** удаляется только пустая ГСК/ГСЗ (409 `GROUP_NOT_EMPTY`), состав сохраняется в `before_value` аудита; группа с FK из консолидаций — follow-up `GROUP_IN_USE` (arm-core#8, T8.1) | | OQ-35 | База процента отклонения: в таблице корректировки % считается к сумме стороны ЮЛ-1 (−79,77 % для 1 000 ↔ 202,32, `31-02-02`), в «Подтверждении КФО клиента» — к версии банка (ст. 2, `31-06`); DBML и Методика (сноска 14) — к **меньшей** сумме. Что считать правильным для `correction.deviation_percent` и `client_kfo_line.deviation_percent`? | макет [31-02] [31-06] vs DBML, Методика | срезы 5–6 | допущение: к меньшей (Методика); макет считать неточным | -| OQ-36 | Экран проверки показывает строки формы **без кода** («в том числе: Основные средства, пригодные к использованию…», `41`); DBML хранит только строки с кодом (`financial_statement_line`), контракт требует `line_code`. Хранить ли расшифровочные строки без кода (и как их показывать)? | макет [41] vs DBML, контракт | срез 3, контракт v1.x | допущение: строки без кода не хранить и не показывать в первой очереди; **Срез 3b: допущение реализовано** — `financial_statement_line` пишется только для строк с кодом (строк без кода в результате нет), экран их не получает. | +| OQ-36 | Экран проверки показывает строки формы **без кода** («в том числе: Основные средства, пригодные к использованию…», `41`); DBML хранит только строки с кодом (`financial_statement_line`), контракт требует `line_code`. Хранить ли расшифровочные строки без кода (и как их показывать)? | макет [41] vs DBML, контракт | срез 3, контракт v1.x | допущение: строки без кода не хранить и не показывать в первой очереди; **Срез 3b: допущение реализовано** — `financial_statement_line` пишется только для строк с кодом (строк без кода в результате нет), экран их не получает. **Закрыт решением владельца 17.10.2026 (Р-8, change `unified-statement-normalization`, контракт v2.0):** строки без кода отдаются сервисами со статусом `detail` (расшифровка под строкой с кодом), `missing` или `unreadable`, хранятся ядром (`financial_statement_line.line_status`, `line_code` nullable, DBML v5.19); в проверки §7, агрегаты и `done` `detail` не входит; экран по умолчанию прячет расшифровки под переключателем «Показать расшифровки (N)». Правила — `05-domain-rules.md` §11.4. Действует с переходом сервисов на v2.0 | | OQ-37 | Строка «Гудвилл 1105» (ФСБУ 4/2023) в распознанной форме (`41`) vs строка `kfo_result_line.line_code = 'GOODWILL'` в разделе I КФО: использовать код 1105 или служебный GOODWILL? Влияет на шаблон строк на фронте и на формулы §7 (1100 = … + 1105?) | макет [41], ФСБУ 4/2023 | срез 3, 6 | допущение: хранить код 1105 как пришёл; строка КФО «GOODWILL» — отдельная, уточнить у методологов | | OQ-38 | ЕХ в макете показывает и **неверифицированные** консолидации («Консолидация 2025 К4», без даты верификации, `62` [83-01]); ЕХ ТЗ 6.2 — срез пишется только после «Верифицирован». Показывать ли в ЛК ЕХ незавершённые консолидации (как «в работе») или только `ex_snapshot`? | макет [62] [83-01] vs ЕХ ТЗ 6.2 | срез 7 | допущение: только срезы `ex_snapshot`; незавершённые — в разделе «Отчётность» | | OQ-39 | Состав «сформированной отчётности» в ЕХ (`83-01`): КФО .xlsx, Шахматка .xlsx и «Консолидированная отчетность .pdf» с датой «Сформирован» и подписями Утвердил/Верифицировал. Нужен ли PDF-экспорт КФО и какие ещё артефакты (сводная ОСВ, корректировки, гудвилл — ЕХ ТЗ 5.2) показывать? | макет [83-01] vs ЕХ ТЗ 5.2 | срез 7 | допущение: xlsx из `payload` на лету для всех `item_type`; PDF — не делать до решения | @@ -146,22 +146,22 @@ | OQ-138 | Единицы «тыс. руб.» в шапке против значений в рублях с копейками в выгрузках 1С (Унагранде Компани/Групп, НПЗ) и тысяч с дробями (Межениновская): кто и где приводит единицы. Примеры: книга НПЗ (`npz/2025-Q3`) — ББ `needs_check`, `1600 = 1700` расходится на 1.94 / 9.02 / 2.48 руб.; ОФР НПЗ и ОФР Унагранде (стр. 1) — `needs_check` по 2100/2200/2300 по той же причине; полные формы по факту сходятся только у Межениновской | `fixtures/statements-xlsx/README.md`, tabular-parser design 2b D-6, D-7, решения №22–24 (R-11, R-13, R-14) | Срез 3 (маппинг форм), §8 (КФО в тыс. руб.) | **закрыт решением владельца 06.10.2026:** единица «тыс. руб.» в шапке считается верной, значения отдаются как в файле; расхождения итогов в пределах округления (до 0,001 % наибольшего числа соотношения) статус не портят — мягкие нарушения «в пределах округления» (arm-tabular-parser#20). Книга НПЗ, ОФР стр. 1 Унагранде — `done`. Принимать ли расхождение и приводить ли единицы — решает КА в ядре (акцепт проверок, Срез 3). **Пересмотрено владельцем 15.10.2026 (AURORA-1528, arm-tabular-parser#42):** значение ББ/ОФР из Excel — как его показывает числовой формат ячейки (масштабирующие запятые `#,##0,` → тысячи, знаки после точки до 2, секции и условия `[=0]"-"`, литералы условных секций как текстовые ячейки, округление половина от нуля, знак от сырого значения); `General` — как в ячейке; неразобранный формат (%, экспонента, дробь, даты, @) — как в ячейке + предупреждение на документ; xlsb — как в файле + предупреждение; ОСВ не затронута. Решение 06.10.2026 «значения как в файле» заменено | | OQ-139 | Форма без колонки кодов: сопоставление по именам строк из шаблонов 66н/ФСБУ (словарь `statement_lines.yaml`, `line_code_confidence < 100`)? Сейчас документ без строк и `needs_check` («колонка кодов не найдена») | решение владельца 06.10.2026 (вопрос 6), tabular-parser design 2b D-4 | §12 п. 2, Срез 3 | открыт: нужен реальный файл без колонки кодов; допущение — не сопоставлять по именам; **связано (Срез 3a):** то же для PDF-пути — справочник кодов по редакциям, имена из OCR при пустой редакции (OQ-146, OQ-150) | | OQ-140 | Страница-продолжение в отдельном файле (1С печатает ББ/ОФР постранично, один файл = страница): склейка в ядре по ЮЛ + период + тип формы; признак `is_continuation` / `form_page` в контракте (MINOR, две необязательных поля) | решение владельца 06.10.2026 (вопрос 2), design 2b D-8 | §12 п. 9, Срез 3, контракт v1.x | допущение принято: страницы разных файлов не склеиваются; страница 2 без шапки — документ с пустой шапкой и `needs_check`, «загрузите форму одним файлом»; **Срез 3b: допущение реализовано** — страницы одной формы из разных файлов не склеиваются: каждый файл — отдельный документ со своим периодом. | -| OQ-141 | Строка без кода, но с суммами в ОФР ФСБУ 4/2023 (НПЗ «Реализация иного имущества…»): хранить ли (OQ-36 расширяется с расшифровочных строк на строки с суммами) | `fixtures/statements-xlsx/npz`, design 2b D-6 | Срез 3, DBML `financial_statement_line` (хранит только строки с кодом) | допущение принято: строки без кода не отдаются, при наличии сумм — предупреждение «строк без кода с суммами пропущено: N» | +| OQ-141 | Строка без кода, но с суммами в ОФР ФСБУ 4/2023 (НПЗ «Реализация иного имущества…»): хранить ли (OQ-36 расширяется с расшифровочных строк на строки с суммами) | `fixtures/statements-xlsx/npz`, design 2b D-6 | Срез 3, DBML `financial_statement_line` (хранит только строки с кодом) | допущение принято: строки без кода не отдаются, при наличии сумм — предупреждение «строк без кода с суммами пропущено: N». **Закрыт решением владельца 17.10.2026 (Р-8):** строка без кода с суммами отдаётся и хранится — `detail` под строкой с кодом (НПЗ «Реализация иного имущества…»), `missing` без строки с кодом выше; подтвердить документ, пока у `unreadable` / `missing` есть суммы, нельзя (409 `LINES_UNRESOLVED`, Р-13). Правила — `05-domain-rules.md` §11.4, §9.5 п. 4 | | OQ-142 | Образец ФСБУ 4/2023 (`templates/ББ после 01.01.2025.xlsx`): «АКТИВ» и код 1110 в одной строке из-за объединённых ячеек; шаблоны (коды есть, сумм и шапки нет) не данные. Нужен ли их разбор вообще | `fixtures/statements-xlsx/templates`, design 2b D-8 | §12 п. 10, тесты | допущение принято: шаблоны дают `needs_check` (шапка пустая, значения `null`); разбор шаблонов как источника данных не планируется, пока владелец не скажет иначе | | OQ-143 | Лист продолжения с **единственной** кодовой строкой (книга НПЗ: лист ОФР с одной строкой 2400): `min_codes` = 3 → лист не распознаётся как форма и пропускается, строка 2400 теряется при `needs_check` документа. Понизить порог / принимать лист по близости к открытому документу ОФР / маркеру продолжения | tabular-parser design 2b: решение №25, O-9 | §12 п. 2, п. 9; полнота ОФР НПЗ | **реализовано в 2b (финальная волна, I-1):** лист с 1–2 кодами форм после листа формы берётся как продолжение (`min_codes_continuation = 1` в словаре) и присоединяется при совпадении числа колонок сумм и типа формы; не присоединён — `needs_check` и предупреждение; первая страница с 1–2 кодами формой не считается. Строка 2400 НПЗ в результате есть | | OQ-144 | Права на удаление на SMB-шаре контура (`Input` — входящие файлы и `.done`, `Output//`, `Tmp`): можно ли сервису `scan-gateway` удалять файлы, и не удаляет ли их сама ИС Сканирование после обработки. Пока `SCAN_CLEANUP=false` — сервис ничего не удаляет (кроме `.part`, ставшего целью публикации записью без замены), в `Input`, `Output` и `Tmp` накапливаются остатки | решение владельца 06.10.2026, change `slice-3a-ocr-normalizer` D-2, решения №26/№27 | scan-gateway (горячие папки), эксплуатация стенда | открыт: допущение — очистка выключена; dev-compose включает `SCAN_CLEANUP=true`. Спросить платформу/владельцев ИС; при положительном ответе — включить флаг на стенде | -| OQ-145 | Редакция формы (`66n` / `fsbu4`) в контракте: нужна ли ядру редакция документа в конверте (необязательное поле `statementDocument`, кандидат MINOR 1.3), или ядро выбирает редакцию по `report_date` само, по тому же правилу §11.1 п. 10. Сейчас scan-gateway выбирает редакцию внутри и наружу её не отдаёт | change `slice-3a-ocr-normalizer`, открытый вопрос 2 | контракт `docs/contracts/` (v1.3), Срез 3b (маппинг форм, справочник кодов в ядре) | **закрыт решением владельца 07.10.2026:** редакция формы в контракт не добавляется (контракт v1.2); ядро определяет её по дате периода документа (`66n` при дате ≤ 2024-12-31, иначе `fsbu4`) и отдаёт в `GET /documents/{id}/statement` (`edition`), как реализовано в Срезе 3b. Досрочное применение ФСБУ 4/2023 до 2025 года — новый OQ при первом реальном случае | -| OQ-146 | Словари §7 (проверки форм) и §12 (справочник кодов `statement_lines.yaml`, формулы ОФР) **по редакциям** 66н / ФСБУ 4/2023 для tabular-parser (2b) и ядра (3b): один набор правил на обе редакции или раздельные; как выбирать редакцию в ядре для Excel-пути | change `slice-3a-ocr-normalizer` (справочник с редакциями только в scan-gateway), OQ-12, OQ-139 | tabular-parser `rules/*.yaml`, ядро (Срез 3b), §7, §12 п. 2 | открыт, **решение владельца 07.10.2026: решается вместе с OQ-150** (состав редакции `fsbu4`); до тех пор допущение прежнее — scan-gateway ведёт свой `api/rules/statement_lines.yaml`, tabular-parser и ядро словарь по редакциям не используют, формулы §7 одни на обе редакции; расширяет OQ-12 и OQ-139 | -| OQ-147 | Закрывающая скобка, распознанная OCR как `}` или `]` (`(7744536}` в `aep_3.json`): дословный перенос `parse_amount` минус не ставит — сумма положительная, `is_recognized = false`. Расширить ли множество закрывающих скобок в §11.1 п. 7 после проверки на стенде | change `slice-3a-ocr-normalizer`, решение №12; владелец 06.10.2026: «пока устроит как есть» | scan-gateway `normalizer/ocr/amounts.py`, §11.1 п. 7 | открыт: допущение — перенос дословный; знак теряется, строка подсвечена `is_recognized = false`, исходный текст — в `raw/` | -| OQ-148 | Длинное/среднее тире «—»/«–» как прочерк: дословный `parse_amount` вырезает тире как «лишний» символ → сумма `null`, но `is_recognized = false` даже при `confidence = 100` (дефис «-» даёт `true`). Лишний `needs_check` на формах с длинным тире. Считать ли прочерк любого вида распознанным | change `slice-3a-ocr-normalizer`, решение №20 | scan-gateway `amounts.py`, §11.1 п. 7, доля `done` на реальных сканах | открыт: допущение — перенос дословный; правило уточняется после прогона на стенде | -| OQ-149 | Строки таблицы OCR, отброшенные классификацией (поведение источника, §11.1 п. 4), не восстанавливаются: с непустым нечисловым кодом (`1150 :`) и с пустым кодом, но с суммами (в `aep_3.json` — «БАЛАНС» и «Выручка»: код пуст, мусор `211(1 \|` стоит в колонке значения). Нормализатор выдаёт предупреждения `строк с нечисловым кодом пропущено: N` и `строк без кода с суммами пропущено: N` (как OQ-141 для Excel), документ — `needs_check`, содержимое строк остаётся в `raw/`. Восстанавливать ли такие строки по наименованию (словарь редакции) или по структуре формы | `fixtures/ocr/aep_3.json`, `aep_6.json`, change `slice-3a-ocr-normalizer`, решение №29, §11.1 п. 4 | scan-gateway `rows.py`/`document.py`, полнота форм, OQ-36, OQ-139, OQ-141 | открыт: допущение — строки пропускаются с предупреждением; аналитик видит расхождение итогов в проверках 3b | -| OQ-150 | Состав редакции `fsbu4` (ФСБУ 4/2023, отчётность с 2025 года) в `api/rules/statement_lines.yaml` scan-gateway: коды и наименования строк ББ и ОФР; источник — Приложения 3–4 к ФСБУ 4/2023 (черновой перечень кодов — шаблоны `fixtures/statements-xlsx/templates/`, OQ-142). Пока `lines: {}` | change `slice-3a-ocr-normalizer`, D-7 | scan-gateway `api/rules/`, эталоны `fixtures/ocr/expected_normalize/` для документов 2025 года, статус `done` | открыт: **данные даёт владелец**, код нормализатора не меняется; до заполнения документы 2025 года идут с именами из OCR, предупреждением «редакция fsbu4 не заполнена» и `needs_check`. Порядок заполнения — `scan-gateway/README.md` **09.10.2026, решение владельца:** состав `fsbu4` (и `66n`) для ядра принят из шаблонов банка — `arm-core/api/rules/statement_lines.yaml` (arm-core#80); словарь scan-gateway — follow-up, копии расходятся (66н scan-gateway — словарь старого ядра). | +| OQ-145 | Редакция формы (`66n` / `fsbu4`) в контракте: нужна ли ядру редакция документа в конверте (необязательное поле `statementDocument`, кандидат MINOR 1.3), или ядро выбирает редакцию по `report_date` само, по тому же правилу §11.1 п. 10. Сейчас scan-gateway выбирает редакцию внутри и наружу её не отдаёт | change `slice-3a-ocr-normalizer`, открытый вопрос 2 | контракт `docs/contracts/` (v1.3), Срез 3b (маппинг форм, справочник кодов в ядре) | **закрыт решением владельца 07.10.2026:** редакция формы в контракт не добавляется (контракт v1.2); ядро определяет её по дате периода документа (`66n` при дате ≤ 2024-12-31, иначе `fsbu4`) и отдаёт в `GET /documents/{id}/statement` (`edition`), как реализовано в Срезе 3b. Досрочное применение ФСБУ 4/2023 до 2025 года — новый OQ при первом реальном случае. **18.10.2026 (change `unified-statement-normalization`):** решение сохраняется — редакция в контракт v2.0 не добавлена; сервисы выбирают редакцию тем же правилом только для упорядоченного перечня кодов (структурная проверка неуверенных кодов, `05-domain-rules.md` §11.6, §11.9) | +| OQ-146 | Словари §7 (проверки форм) и §12 (справочник кодов `statement_lines.yaml`, формулы ОФР) **по редакциям** 66н / ФСБУ 4/2023 для tabular-parser (2b) и ядра (3b): один набор правил на обе редакции или раздельные; как выбирать редакцию в ядре для Excel-пути | change `slice-3a-ocr-normalizer` (справочник с редакциями только в scan-gateway), OQ-12, OQ-139 | tabular-parser `rules/*.yaml`, ядро (Срез 3b), §7, §12 п. 2 | открыт, **решение владельца 07.10.2026: решается вместе с OQ-150** (состав редакции `fsbu4`); до тех пор допущение прежнее — scan-gateway ведёт свой `api/rules/statement_lines.yaml`, tabular-parser и ядро словарь по редакциям не используют, формулы §7 одни на обе редакции; расширяет OQ-12 и OQ-139. **18.10.2026:** словарь §7 — единственный, в общем коде `arm_statements` (копия ядра удаляется), полный и упрощённый наборы по `form_variant`; раздельные формулы по редакциям по-прежнему не вводятся | +| OQ-147 | Закрывающая скобка, распознанная OCR как `}` или `]` (`(7744536}` в `aep_3.json`): дословный перенос `parse_amount` минус не ставит — сумма положительная, `is_recognized = false`. Расширить ли множество закрывающих скобок в §11.1 п. 7 после проверки на стенде | change `slice-3a-ocr-normalizer`, решение №12; владелец 06.10.2026: «пока устроит как есть» | scan-gateway `normalizer/ocr/amounts.py`, §11.1 п. 7 | открыт: допущение — перенос дословный; знак теряется, строка подсвечена `is_recognized = false`, исходный текст — в `raw/`. **Закрыт дизайном, согласованным владельцем 17.10.2026 (change `unified-statement-normalization`, шаг 4, Р-7):** `}` и `]` принимаются как закрывающая скобка — «(7744536}» → −7 744 536, `is_recognized = false` (исправление); знак члена `abs_terms` подтверждается конвенцией колонки. `05-domain-rules.md` §11.5 п. 2. Действует с переходом scan-gateway на v2.0 | +| OQ-148 | Длинное/среднее тире «—»/«–» как прочерк: дословный `parse_amount` вырезает тире как «лишний» символ → сумма `null`, но `is_recognized = false` даже при `confidence = 100` (дефис «-» даёт `true`). Лишний `needs_check` на формах с длинным тире. Считать ли прочерк любого вида распознанным | change `slice-3a-ocr-normalizer`, решение №20 | scan-gateway `amounts.py`, §11.1 п. 7, доля `done` на реальных сканах | открыт: допущение — перенос дословный; правило уточняется после прогона на стенде. **Закрыт решением владельца 18.10.2026 (Р-22, утверждено решение спеки S-7, change `unified-statement-normalization`):** прочерк любого вида (`-`, `–`, `—`, `−` без цифр) — пустое значение, `amount = null`, `is_recognized = true` (как пустая ячейка, Р-2); тире на месте потерянной цифры ловит §7 (пустой член посчитанного соотношения); `05-domain-rules.md` §11.5 п. 2. Действует с переходом scan-gateway на контракт 2.0 | +| OQ-149 | Строки таблицы OCR, отброшенные классификацией (поведение источника, §11.1 п. 4), не восстанавливаются: с непустым нечисловым кодом (`1150 :`) и с пустым кодом, но с суммами (в `aep_3.json` — «БАЛАНС» и «Выручка»: код пуст, мусор `211(1 \|` стоит в колонке значения). Нормализатор выдаёт предупреждения `строк с нечисловым кодом пропущено: N` и `строк без кода с суммами пропущено: N` (как OQ-141 для Excel), документ — `needs_check`, содержимое строк остаётся в `raw/`. Восстанавливать ли такие строки по наименованию (словарь редакции) или по структуре формы | `fixtures/ocr/aep_3.json`, `aep_6.json`, change `slice-3a-ocr-normalizer`, решение №29, §11.1 п. 4 | scan-gateway `rows.py`/`document.py`, полнота форм, OQ-36, OQ-139, OQ-141 | открыт: допущение — строки пропускаются с предупреждением; аналитик видит расхождение итогов в проверках 3b. **18.10.2026:** остаётся открытым (Р-8); с v2.0 такие строки не пропадают — приходят со статусами `unreadable` / `missing` / `detail` и сырым текстом, аналитик назначает код или отмечает расшифровкой (`PATCH …/statement lines`); восстановление по наименованию не делается | +| OQ-150 | Состав редакции `fsbu4` (ФСБУ 4/2023, отчётность с 2025 года) в `api/rules/statement_lines.yaml` scan-gateway: коды и наименования строк ББ и ОФР; источник — Приложения 3–4 к ФСБУ 4/2023 (черновой перечень кодов — шаблоны `fixtures/statements-xlsx/templates/`, OQ-142). Пока `lines: {}` | change `slice-3a-ocr-normalizer`, D-7 | scan-gateway `api/rules/`, эталоны `fixtures/ocr/expected_normalize/` для документов 2025 года, статус `done` | открыт: **данные даёт владелец**, код нормализатора не меняется; до заполнения документы 2025 года идут с именами из OCR, предупреждением «редакция fsbu4 не заполнена» и `needs_check`. Порядок заполнения — `scan-gateway/README.md` **09.10.2026, решение владельца:** состав `fsbu4` (и `66n`) для ядра принят из шаблонов банка — `arm-core/api/rules/statement_lines.yaml` (arm-core#80); словарь scan-gateway — follow-up, копии расходятся (66н scan-gateway — словарь старого ядра). **18.10.2026:** словарь имён шлюза `api/rules/statement_lines.yaml` удаляется (Р-3: наименования — только из источника, справочное имя — в ядре); для сервисов нужен состав кодов `fsbu4` в упорядоченном перечне кодов `arm_statements` — источник тот же (шаблоны банка + печатные формы 1С) | | OQ-151 | Эмулятор ИС Сканирование: файл, совпавший по sha256 с PDF из `SCAN_FIXTURES_DIR`, у которого нет JSON-набора (`<имя>.json`), отвечает «нет пары» (`INVALID_DOCUMENT`) и **не** уходит на `SCAN_EMULATOR_DEFAULT`. Пробел спеки эмулятора: считать ли такой файл промахом и отдавать набор по умолчанию | change `slice-3a-ocr-normalizer`, спека `scan-emulator` | scan-gateway `scan_emulator`, dev-стенд (`dev/qa-guide.md` §6) | открыт: допущение — поведение как есть; PDF без JSON-пары в `fixtures/ocr` не класть | -| OQ-152 | Повтор кода строки в таблице OCR: остаётся **первая** строка (паритет с Excel-путём, §12 п. 5; решение №15), но в `aep_6.json` (ОФР) код 2410 повторён, оставленная первая строка пуста, а отброшенная содержит `(649)`. Выбрать правило «первая строка с суммой» вместо «первая» (для `aep_3` оно даёт иной результат, чем для `aep_6` — нужна проверка на стенде) | change `slice-3a-ocr-normalizer`, решение №34 (R-16), финальное ревью I-2 | scan-gateway `document.py`, §11.1 п. 4, Excel-путь §12 п. 5 | открыт: допущение — первая строка; предупреждение «код N повторяется» сигнализирует аналитику, сумма отброшенной строки остаётся в `raw/` | +| OQ-152 | Повтор кода строки в таблице OCR: остаётся **первая** строка (паритет с Excel-путём, §12 п. 5; решение №15), но в `aep_6.json` (ОФР) код 2410 повторён, оставленная первая строка пуста, а отброшенная содержит `(649)`. Выбрать правило «первая строка с суммой» вместо «первая» (для `aep_3` оно даёт иной результат, чем для `aep_6` — нужна проверка на стенде) | change `slice-3a-ocr-normalizer`, решение №34 (R-16), финальное ревью I-2 | scan-gateway `document.py`, §11.1 п. 4, Excel-путь §12 п. 5 | открыт: допущение — первая строка; предупреждение «код N повторяется» сигнализирует аналитику, сумма отброшенной строки остаётся в `raw/`. **Закрыт решением владельца 17.10.2026 (Р-4):** повтор кода — законный случай, все строки сохраняются со статусом `ok`, для §7 и всего после — сумма по коду (в `aep_6` 2410 = пустая строка + `(649)`); неверно прочитанный совпавший код ловит структурная проверка (Р-16). `05-domain-rules.md` §11.4 | | OQ-153 | Журналировать ли скачивание первички (`GET /files/{id}/content`) и CSV чек-листа (ТЗ 8.2: перечень событий журнала включает «выгрузку»)? Экспорт сводной ОСВ в xlsx пишет `audit_event(EXPORT)` (Срез 4), а скачивание исходного файла и CSV чек-листа в Срезе 3b — нет; комментарии к документу тоже не аудируются (`comment` хранит автора и время) | change `slice-3b-statements-api`, design D-10, D-11, D-13, решение №8; ТЗ 8.2 | `audit_event`, `GET /files/{id}/content`, `GET …/checklist?format=csv`, §9.5 п. 6 | **закрыт решением владельца 07.10.2026 (вариант А):** скачивание исходного файла (`GET /files/{id}/content`, это же и просмотр PDF) и CSV чек-листа в `audit_event` не пишутся; `EXPORT` пишет только экспорт сводной ОСВ в xlsx (Срез 4) | | OQ-154 | Автозамена файла работает на уровне файла (ключ документа: тип, счёт/NULL, год, квартал, нарастающий): исправленный отдельный ОФР заменит весь прежний файл-книгу «ББ + ОФР» того же периода (`SUPERSEDED`), а подтверждённый ББ из неё отвяжется от версий «в работе». Допустима ли такая замена на уровне файла, или формы одного периода нужно заменять по документам | финальное ревью Среза 3b (заметка контроллера), change `slice-3b-statements-api` | Срез 3b / 5 | **закрыт решением владельца 07.10.2026 (вариант А):** замена остаётся на уровне файла (DBML `file_status.SUPERSEDED`, ТЗ 8.2); если у заменённого файла есть документы, ключа которых нет в новом, — предупреждение в `metrics.mapping_warnings` нового файла (какие документы выпали, был ли `CONFIRMED`, из каких версий сняты привязки). Правило — `05-domain-rules.md` §9.5 п. 3; реализация — RSHB/arm-core#58 | | OQ-155 | Эталонная ручная консолидация (`fixtures/golden/Консолидация_Группа РБ.xlsx`, лист `01.10.2024`, «в т. ч. взаимные обязательства») элиминирует у каждой компании **полную сумму её строк на компании группы** (Счастливочная 1230 = 108 062 тыс. = 62.01 «БП Производство» 106 311 116,46 + 62.01 «БП Аренда» 1 401 330 + 76.06 350 000; РБ 1520 = 106 311 тыс.), а ТЗ (DOC-223044, §4–§5) — по **парам** с `min` и порогом 5 %. Какой алгоритм верен | разбор OQ перед Срезом 5 (08.10.2026), сверка эталона с golden ОСВ РБ/Счастливочной Q3 2024 | Срез 5 (матчинг §5, тест сверки с эталоном), Срез 6 (КФО) | **закрыт решением владельца 08.10.2026: алгоритм ТЗ** — пары, `min`, порог 5 % (§4–§5); эталон сверяется только по точным парам (106 311 116,46 по правилу № 6), асимметрия эталона (1 751 тыс. у Счастливочной) — ожидаемое расхождение, описывается в тесте. Связано: OQ-07 (строго 1:1 или гибрид — ждёт владельца), OQ-08 | -| OQ-156 | Ответ ИС Сканирование склеивает соседние строки формы в одну: код `1500\n1700`, наименование `Итого по разделу V\n\nБАЛАНС`, суммы `917 775\n2 371 333` (ББ РБ и Счастливочной за 1 кв. 2025, `fixtures/ocr/Бухгалтерская отчетность 1 кв.25.json`, `БО 1 кв 2025.json`). Нормализатор отбрасывал строку как нечисловой код — в документе не было 1500 и 1700 | прогон 1 кв. 2025 на реальных ответах ИС (arm-ka-fixtures#16) | scan-gateway `rows.py`, §11.1 п. 4 | **решено** (владелец 08.10.2026): разлеплять, если в каждой непустой ячейке значений столько же частей, сколько кодов; иначе — как раньше; предупреждение «строк с несколькими кодами разлеплено: N», `needs_check`; реализация — RSHB/arm-scan-gateway#16 | +| OQ-156 | Ответ ИС Сканирование склеивает соседние строки формы в одну: код `1500\n1700`, наименование `Итого по разделу V\n\nБАЛАНС`, суммы `917 775\n2 371 333` (ББ РБ и Счастливочной за 1 кв. 2025, `fixtures/ocr/Бухгалтерская отчетность 1 кв.25.json`, `БО 1 кв 2025.json`). Нормализатор отбрасывал строку как нечисловой код — в документе не было 1500 и 1700 | прогон 1 кв. 2025 на реальных ответах ИС (arm-ka-fixtures#16) | scan-gateway `rows.py`, §11.1 п. 4 | **решено** (владелец 08.10.2026): разлеплять, если в каждой непустой ячейке значений столько же частей, сколько кодов; иначе — как раньше; предупреждение «строк с несколькими кодами разлеплено: N», `needs_check`; реализация — RSHB/arm-scan-gateway#16. **Пересмотрено решением владельца 17.10.2026 (Р-17):** разлепленные строки не держат документ в `needs_check` безусловно — их значения `is_recognized = false` и нуждаются в покрытии §7, при сошедшихся соотношениях не подсвечиваются; разлепление — предупреждение `ROW_SPLIT` уровня `info`; нечитаемая часть кода становится строкой `unreadable`. `05-domain-rules.md` §11.4 | | OQ-157 | Чистый документ (без замечаний) требовал ручного «Подтвердить» (`RECOGNIZED` → `CONFIRMED`), хотя строки ОСВ не правятся (OQ-114) и ТЗ 03_9 п. 4 / 10_10 даёт «Загружен» сразу при отсутствии замечаний; на комплекте 1 кв. 2025 — 16 пустых кликов | прогон владельца 08.10.2026 | arm-core `services/auto_confirm.py`, `result_mapping`, `primary_files.delete`; §9.3, §9.5 п. 4–5 | **решено** (владелец 08.10.2026): чистый документ при маппинге сразу `CONFIRMED` системой (`confirmed_by` NULL), переход версии — от загрузившего; удаление автоподтверждённого файла — пока версии «в работе»; RSHB/arm-core#64, change `auto-confirm-clean-documents` | | OQ-158 | В «Осталось N» (строки свода без ЮЛ и без ИНН) попадают строки аналитики, которые не являются контрагентами-организациями: «Ведомость в счет з/платы», подотчётные лица, физлица и т. п. — на РБ 2025 К1 таких большинство из 1291 строки без ИНН, и довести баннер до нуля сопоставлением с ЮЛ нельзя. Нужно ли отличать такие строки (признак «не организация» вручную / по словарю / по счёту) и исключать их из «Осталось N» и предупреждения [31-00] | жалоба владельца на «Указать» в сводной ОСВ (08.10.2026), RSHB/arm-core#74 | arm-core `domain/consolidated_osv.needs_mapping`, §3, экраны [30], [32-osv-no-inn], [31-00] | открыт: до решения такие строки считаются несопоставленными (EXTERNAL), переход к следующему шагу ими не блокируется — блокировки в API нет | | OQ-159 | Note DBML `recognition_job.reply_deadline` «таймаут ожидания (OCR — 5 мин)» расходится с решением владельца 09.10.2026: срок ответа OCR — 20 мин (`DEFAULT_REPLY_TIMEOUT` в arm-core, `SCAN_TIMEOUT_SECONDS=1200` в scan-gateway). Поправить note при следующей версии DBML | решение владельца 09.10.2026 (не выпускать версию DBML ради note); arm-core #87, arm-scan-gateway #26, arm-ka-docs #84 | DBML `recognition_job`, `05-domain-rules.md` §9.4 | **закрыт 09.10.2026:** note исправлен в DBML v5.18 (docs #90) | @@ -170,6 +170,7 @@ | OQ-162 | ОСВ 1С, шапка варианта 2 «Детализация по субсчетам, субконто: Контрагенты, Контрагенты.ИНН» (ООО «Зерновая компания „Новопетровская“», ОСВ 60 за 9 мес. 2025, замечание банка 13.10.2026): ИНН выводится **отдельной строкой под** контрагентом (уровень глубже, те же суммы, ИНН числом). Парсер принимал «Контрагенты.ИНН» за второй уровень контрагентов: в «Контрагент / аналитика» — `2370012036.0`, наименования нет, ИНН у строк `null` → нет признака ГК/ГСК/Внешний, суммы контрагента в своде дважды | замечание банка 13.10.2026; RSHB/arm-tabular-parser#32 | tabular-parser §1.1, §1.3; свод и резолв контрагента в ядре | **решено владельцем 13.10.2026:** `<группировка>.ИНН` — реквизит, не уровень; строка ИНН склеивается с родительским контрагентом (ИНН — родителю, строка из `rows` убирается, дети — к контрагенту), только если она единственная у родителя и суммы равны; иначе строка `OTHER` и `needs_check`; пустая ячейка ИНН (физлицо) — склейка без ИНН (рулинг R-1); целое число из ячейки xls — без «.0». `05-domain-rules.md` §1.1, §1.3; **реализовано** RSHB/arm-tabular-parser#32 (713 из 716 контрагентов файла получили ИНН, 3 физлица без ИНН). Правило строки ИНН заменено OQ-163 (13.10.2026). | | OQ-163 | ОСВ, где ИНН контрагента выведен **отдельной строкой** рядом с наименованием, в любой раскладке: над / под × в дереве / не в дереве × с суммами / без (требование владельца 13.10.2026). До решения парсер склеивал только «над, не в дереве, без сумм» (OQ-130) и «под, в дереве» при подсказке «Контрагенты.ИНН» (OQ-162); «под, не в дереве, без сумм» отдавал ИНН следующему контрагенту | владелец, 13.10.2026; RSHB/arm-tabular-parser#35, #34 | tabular-parser §1.1, §1.3 | **решено владельцем 13.10.2026:** без подсказки в шапке строка ИНН — только с верной контрольной суммой ИНН (во всех раскладках, включая OQ-130); с подсказкой — без проверки, порядок элементов задаёт направление; «над, в дереве» — только единственный ребёнок; соседи — по суммам, без сумм — по направлению листа (шапка → пары по суммам → однозначные блоки); неоднозначное — строка `OTHER`, `needs_check`; ведущий ноль числовой строки ИНН (#34). Уточнения владельца (H-1): строка из 10/12 цифр с неверной контрольной суммой не склеивается, остаётся обычной строкой, предупреждение «похожа на ИНН …», `needs_check` (синтетика `s28`); (H-2): если пара найдена, но раскладка противоречит направлению подсказки `<группировка>.ИНН`, строка склеивается, но выдаётся отдельное предупреждение «стоит под/над наименованием … а шапка указывает над/под — проверьте» и `needs_check`. `05-domain-rules.md` §1.3; **реализовано** RSHB/arm-tabular-parser#35 | | OQ-164 | Субсчета 76 с налоговой природой в ВГО: исключать ли **76.АВ** «НДС по авансам и предоплатам» из ДЗ и зеркально **76.ВА** «НДС по авансам выданным» из КЗ (правила № 5–8 и результирующая таблица §6: «76 кроме 76.07.5/76.07.9»). В ОСВ Счастливочной на 30.09.2024 на 76.АВ — 2 366 666,69 с контрагентом АО «Русский бисквит»: результирующая таблица даёт ДЗ 110 429 113,15, эталон (`fixtures/golden/Консолидация_Группа РБ.xlsx`, лист `01.10.2024`, строка 1230 «в т. ч. взаимные») — 108 062 тыс., ровно без 76.АВ (107 712 446,46 + 350 000 по 76.06 = 108 062 446,46). Есть ли ещё субсчета 76, которые нужно исключать | ревью RSHB/arm-core#114 (шахматка, AURORA-1537), 15.10.2026; вопрос аналитику — AURORA-1373 | `elimination_rule` (Прил. 3, данные), §4, §6; `RESULT_ARTICLES` шахматки; сверка с эталоном (OQ-155) | открыт: до ответа аналитика/методологов расчёт по §4/§6 как есть (76.АВ входит в ДЗ); исключение — правка правил-данных и §4/§6, не кода шахматки | - -Служебные идентификаторы в DBML/аналитике: `ANL-nnn` — замечания аналитика к макету; `ARCH-nnn` — архитектурные решения; -`DECISION-n` — решения по модели; `FIX-nn` — исправления дефектов v4. +| OQ-165 | Склейка двух половинок одной формы из **одного** файла (Р-15): Excel с листом-продолжением другого типа, двойная печать формы, две таблицы ИС одного типа и периода. Сейчас вторая форма того же типа и периода хранится отдельным документом (Р-10), ни одна не автоподтверждается (Р-18), подтверждение второй при подтверждённой первой — 409 `DUPLICATE_FORM`; склейки нет — файл перезагружают. Нужна ли склейка (кнопка «объединить формы») и по каким правилам | change `unified-statement-normalization`, Р-15, Р-18 | arm-core (`document.file_ordinal`, подтверждение), arm-web, §9.5 п. 2 | открыт: от ИС Сканирование такой случай не ожидается (владелец 17.10.2026), основной источник — Excel; допущение — склейки нет, файл перезагружают | +| OQ-166 | Упрощённая форма ББ/ОФР (приказ 66н, прил. 5): признак — по структуре кодов (ББ: есть 1600, нет 1100 и 1200; ОФР: есть 2400 или 2110, нет 2100, 2200, 2300), ОКУД 0710096 признаком не служит. Набор соотношений §7 и перечень кодов упрощённой формы заданы по бланку (`05-domain-rules.md` §7, решение S-5 спеки; ББ — жёсткие, ОФР `2400 = 2110 − 2120 − 2330 + 2340 − 2350 − 2410` — мягкое, как `2400` полного набора, решение владельца 18.10.2026, Р-22; перечни — `contracts/statement-code-orders.yaml`, у `fsbu4` — как у 66н) без образца в `fixtures/` | change `unified-statement-normalization`, design (Decisions № 14) | `arm_statements` `statement_checks.yaml`, перечень кодов, `form_variant` | открыт: допущение — формулы §7 «Упрощённый набор»; нужен реальный образец упрощённой отчётности (Excel и скан) в `fixtures/` для проверки признака и формул | +| OQ-167 | Несколько отчётных периодов в одном файле, отданном в ИС Сканирование (например, ББ за два квартала в одном PDF): шапка в json ИС одна на json и копируется во все его таблицы | change `unified-statement-normalization`, design шаг 1 (допущение владельца 17.10.2026) | scan-gateway (адаптер шага 1), период документа | допущение владельца: ИС отдаёт разные периоды разными json; образца нет — проверить на первом таком файле, при расхождении — шапка из json неверна для части таблиц | +| OQ-168 | Коды упорядоченного перечня форм без образца в `fixtures/` (`contracts/statement-code-orders.yaml`, §11.9): в перечень вошли только коды шаблонов банка и коды печатных форм 1С и сканов, найденные в `fixtures/` (1105, 1130, 1140, 1215, 1231, 1530, 2420, 2460, 2530, 2930, повторы 1220 / 1520 / 2410 у `fsbu4`, раздел III НКО у `66n`). Не подтверждены образцом: 1120 в `fsbu4`; 1105, 1215, 2420 в `66n`; 2421, 2430, 2450 (прежние строки ОФР 66н); 2930 и раздел III НКО в `fsbu4`; клиентский 1231 в `fsbu4`; упрощённые перечни обеих редакций (OQ-166). Добавлять ли их без образца | change `unified-statement-normalization`, ревью спеки K-5 (18.10.2026) | `arm_statements` (перечень кодов, структурное подтверждение неуверенного кода), ядро (`CODE_NOT_IN_EDITION`) | открыт до появления образцов; **временное правило принято владельцем 18.10.2026 (Р-26):** коды без образца (2421 / 2430 / 2450 в `66n`, 1120 и другие в `fsbu4`) в перечень не входят; неуверенный такой код на скане не подтверждается структурой — `needs_review` строки, `needs_check`; у Excel и уверенного кода — только `info` ядра `CODE_NOT_IN_EDITION`. Пополнение — строкой в yaml с источником (файл fixtures), когда образец появится | diff --git a/context/08-source-docs-index.md b/context/08-source-docs-index.md index f3129f2..44091e7 100644 --- a/context/08-source-docs-index.md +++ b/context/08-source-docs-index.md @@ -48,9 +48,9 @@ | Файл | Что | |---|---| -| `data-model-v5.18.dbml` | источник истины; changelog v5.1…v5.18 в шапке; блоки «ИСТОЧНИК» над каждой таблицей (v5.13…v5.17 остаются рядом как история) | -| `entities-v5.18.md` | описание сущностей по частям 01–09, таблицы «Что изменилось» по версиям, сценарий «как читать модель», открытые вопросы (v5.13…v5.17 остаются как история) | -| `diagrams/part01…09.{svg,png}` | диаграммы v5.18 по частям; перерисовываются `make diagrams` (`scripts/gen_model_diagrams.py`) | +| `data-model-v5.19.dbml` | источник истины; changelog v5.1…v5.19 в шапке; блоки «ИСТОЧНИК» над каждой таблицей (v5.13…v5.18 остаются рядом как история) | +| `entities-v5.19.md` | описание сущностей по частям 01–09, таблицы «Что изменилось» по версиям, сценарий «как читать модель», открытые вопросы (v5.13…v5.18 остаются как история) | +| `diagrams/part01…09.{svg,png}` | диаграммы v5.19 по частям; перерисовываются `make diagrams` (`scripts/gen_model_diagrams.py`) | | `source/tz/data-model-part1_4.dbml` (лежит не в `model/`, а в экспорте ТЗ) | v4 часть 1 — историческое | ## Макет UI diff --git a/context/KICKOFF-PROMPT.md b/context/KICKOFF-PROMPT.md index 3c958d5..bf65220 100644 --- a/context/KICKOFF-PROMPT.md +++ b/context/KICKOFF-PROMPT.md @@ -9,8 +9,8 @@ 1. `CLAUDE.md` (workspace) и `docs/context/00-workspace-layout.md` — как устроена рабочая область, статус аналитики, макет. 2. `docs/context/01-project-overview.md`, `02-glossary.md` — предметная область. -3. `docs/context/03-architecture.md`, `04-data-model-guide.md`, затем **весь** `docs/model/data-model-v5.18.dbml` и - `docs/model/entities-v5.18.md`. +3. `docs/context/03-architecture.md`, `04-data-model-guide.md`, затем **весь** `docs/model/data-model-v5.19.dbml` и + `docs/model/entities-v5.19.md`. 4. `docs/context/05-domain-rules.md` — алгоритмы (особенно §1.0 и §12 — парсеры ищут структуру по содержимому); `06-test-data.md`; `07-open-questions.md`; `10-user-journey.md`; `09-delivery-plan.md`. 5. `arm-core/`, `tabular-parser/`, `scan-gateway/` — три репозитория, развёрнутые из нулевого коммита шаблона команды, @@ -37,7 +37,7 @@ NATS, MinIO, эмулятор ИС Сканирование), контракт ` `07-open-questions.md`; если нужен ответ для среза — делай допущение по правилам из этого файла. Ограничения на всё время работы: -- имена таблиц, колонок и enum'ов — ровно как в DBML v5.18; поля, которых нет в DBML, не изобретать; +- имена таблиц, колонок и enum'ов — ровно как в DBML v5.19; поля, которых нет в DBML, не изобретать; - алгоритмы — только из `05-domain-rules.md`; из страниц ТЗ напрямую не кодировать; - парсеры: никаких координат ячеек, букв колонок, имён листов и номеров строк в коде — структура по содержимому, синонимы в `rules/*.yaml`; фикстуры — регрессия, не спецификация; diff --git a/contracts/examples/result-error.json b/contracts/examples/result-error.json index 54af7cb..b043599 100644 --- a/contracts/examples/result-error.json +++ b/contracts/examples/result-error.json @@ -1,8 +1,11 @@ { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", - "service": { "name": "tabular-parser", "version": "0.1.0" }, + "service": { + "name": "tabular-parser", + "version": "0.1.0" + }, "status": "error", "error": { "code": "NOT_IMPLEMENTED", diff --git a/contracts/examples/result-message-done.json b/contracts/examples/result-message-done.json index 6d56f17..4e47bd1 100644 --- a/contracts/examples/result-message-done.json +++ b/contracts/examples/result-message-done.json @@ -1,14 +1,17 @@ { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", "status": "done", - "service": { "name": "scan-gateway", "version": "0.1.0" }, + "service": { + "name": "scan-gateway", + "version": "0.1.0" + }, "result_s3": { "bucket": "arm-ka", "key": "results/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/result.json" }, - "result_schema_version": "1.0", + "result_schema_version": "2.0", "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", "metrics": { "duration_ms": 18400, diff --git a/contracts/examples/result-message-error.json b/contracts/examples/result-message-error.json index f5ec77a..6da980c 100644 --- a/contracts/examples/result-message-error.json +++ b/contracts/examples/result-message-error.json @@ -1,9 +1,12 @@ { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "status": "error", - "service": { "name": "tabular-parser", "version": "0.1.0" }, + "service": { + "name": "tabular-parser", + "version": "0.1.0" + }, "error": { "code": "NOT_IMPLEMENTED", "message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet" diff --git a/contracts/examples/result-message-processing.json b/contracts/examples/result-message-processing.json index 2087150..9136790 100644 --- a/contracts/examples/result-message-processing.json +++ b/contracts/examples/result-message-processing.json @@ -1,7 +1,10 @@ { - "schema_version": "1.0", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", "status": "processing", - "service": { "name": "tabular-parser", "version": "0.1.0" } + "service": { + "name": "tabular-parser", + "version": "0.1.0" + } } diff --git a/contracts/examples/result-osv-minimal.json b/contracts/examples/result-osv-minimal.json index b0a9e97..f54525f 100644 --- a/contracts/examples/result-osv-minimal.json +++ b/contracts/examples/result-osv-minimal.json @@ -1,8 +1,11 @@ { - "schema_version": "1.2", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", - "service": { "name": "tabular-parser", "version": "0.1.0" }, + "service": { + "name": "tabular-parser", + "version": "0.1.0" + }, "status": "done", "documents": [ { @@ -24,7 +27,11 @@ "header_variant": 1 }, "group_schema": [ - { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } + { + "type": "COUNTERPARTY", + "name": "Контрагенты", + "has_inn": true + } ], "columns": { "opening_debit": "C", @@ -70,14 +77,18 @@ "turnover_credit": "200.50", "closing_debit": "0.00", "closing_credit": "700.50", - "extra": { "КПП": "352801001" } + "extra": { + "КПП": "352801001" + } } ], "checks": { "total_row_matches_root": true, "balance_identity_violations": [], "parent_sum_violations": [] - } + }, + "status": "done", + "warnings": [] } ] } diff --git a/contracts/examples/result-osv-needs-check.json b/contracts/examples/result-osv-needs-check.json index 81d64a8..6827c89 100644 --- a/contracts/examples/result-osv-needs-check.json +++ b/contracts/examples/result-osv-needs-check.json @@ -1,15 +1,15 @@ { - "schema_version": "1.1", + "schema_version": "2.0", "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", - "service": { "name": "tabular-parser", "version": "1.0.0" }, + "service": { + "name": "tabular-parser", + "version": "1.0.0" + }, "status": "needs_check", "metrics": { "rows": 3, - "warnings": [ - "sheet=Лист_1 hierarchy=outline", - "§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3" - ] + "warnings": [] }, "documents": [ { @@ -28,8 +28,15 @@ "header_variant": 1 }, "group_schema": [ - { "type": "ACCOUNT", "name": "Счет" }, - { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } + { + "type": "ACCOUNT", + "name": "Счет" + }, + { + "type": "COUNTERPARTY", + "name": "Контрагенты", + "has_inn": true + } ], "columns": { "opening_debit": "D", @@ -76,7 +83,9 @@ "turnover_credit": "200.50", "closing_debit": "0.00", "closing_credit": "700.50", - "extra": { "КПП": "352801001" } + "extra": { + "КПП": "352801001" + } }, { "row_number": 3, @@ -117,9 +126,24 @@ ], "checks": { "total_row_matches_root": true, - "balance_identity_violations": [3], + "balance_identity_violations": [ + 3 + ], "parent_sum_violations": [] - } + }, + "status": "needs_check", + "warnings": [ + { + "code": "OSV_HIERARCHY_BY_OUTLINE", + "severity": "info", + "message": "иерархия строк — по группировке листа (outline)" + }, + { + "code": "OSV_BALANCE_IDENTITY", + "severity": "info", + "message": "§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3" + } + ] } ] } diff --git a/contracts/examples/result-statement-excel.json b/contracts/examples/result-statement-excel.json index 260528b..5e7b311 100644 --- a/contracts/examples/result-statement-excel.json +++ b/contracts/examples/result-statement-excel.json @@ -1,44 +1,99 @@ { - "schema_version": "1.2", + "schema_version": "2.0", "job_id": "3f1c7a2e-5b64-4d0a-9c1e-8a7b6d5e4f30", "file_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", - "service": { "name": "tabular-parser", "version": "0.3.0" }, - "status": "done", - "metrics": { "duration_ms": 412, "rows": 7, "warnings": ["sheet=Бухгалтерский баланс. Лист 1 значений с дробной частью при единице тыс. руб.: 3"] }, + "service": { "name": "tabular-parser", "version": "0.4.0", "statements_version": "abc1234" }, + "status": "needs_check", + "metrics": { "duration_ms": 412, "rows": 12, "warnings": [] }, "documents": [ { "document_type": "BALANCE", "locator": "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2", - "header": { "form_name": "Бухгалтерский баланс", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "inn": "7700000000", "inn_confidence": 100, "report_date_text": "на 30 сентября 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710001", "okpo": "12345678" }, + "status": "done", + "form_variant": "full", + "warnings": [ + { "code": "FRACTIONAL_IN_THOUSANDS", "severity": "info", "message": "значений с дробной частью при единице тыс. руб.: 6 из 14" } + ], + "header": { + "form_name": "Бухгалтерский баланс", + "organization_name": "ООО \"Пример\"", "organization_name_text": "ООО \"Пример\"", "organization_name_confidence": 100, + "inn": "7700000000", "inn_text": "7700000000", "inn_confidence": 100, + "report_date": "2025-09-30", "report_date_text": "на 30 сентября 2025 г.", "report_date_confidence": 100, + "units": "тыс. руб.", "units_text": "в тыс. рублей", "units_confidence": 100, + "okud": "0710001", "okud_text": "0710001", "okud_confidence": 100, + "okpo": "12345678", "okpo_text": "12345678", "okpo_confidence": 100, + "okei": "384", "okei_text": "384", "okei_confidence": 100, + "needs_review_fields": [] + }, "columns": [ - { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, - { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + { "column_index": 3, "label": "На 30 сентября 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" }, + { "column_index": 4, "label": "На 31 декабря 2024 г.", "label_confidence": 100, "period_date": "2024-12-31", "period_kind": "absolute" } ], "lines": [ - { "line_code": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, - { "line_code": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "900000.00", "confidence": 100, "is_recognized": true } ] }, - { "line_code": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, - { "line_code": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": null, "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": null, "confidence": 100, "is_recognized": true } ] }, - { "line_code": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] } + { "row_index": 0, "ref": "'Бухгалтерский баланс. Лист 1'!C14", "line_status": "ok", "line_code": "1110", "line_code_text": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 1, "ref": "'Бухгалтерский баланс. Лист 1'!C15", "line_status": "ok", "line_code": "11101", "line_code_text": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "900000.00", "text": "900 000,00", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 2, "ref": "'Бухгалтерский баланс. Лист 1'!C16", "line_status": "ok", "line_code": "1100", "line_code_text": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 3, "ref": "'Бухгалтерский баланс. Лист 1'!C17", "line_status": "ok", "line_code": "1200", "line_code_text": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": null, "text": "-", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": null, "text": "", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 4, "ref": "'Бухгалтерский баланс. Лист 1'!C18", "line_status": "ok", "line_code": "1600", "line_code_text": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 5, "ref": "'Бухгалтерский баланс. Лист 2'!C5", "line_status": "ok", "line_code": "1370", "line_code_text": "1370", "line_code_confidence": 100, "line_name": "Нераспределенная прибыль (непокрытый убыток)", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 6, "ref": "'Бухгалтерский баланс. Лист 2'!C6", "line_status": "ok", "line_code": "1300", "line_code_text": "1300", "line_code_confidence": 100, "line_name": "Итого по разделу III", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 7, "ref": "'Бухгалтерский баланс. Лист 2'!C7", "line_status": "ok", "line_code": "1700", "line_code_text": "1700", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "586260.26", "text": "586 260,26", "confidence": 100, "is_recognized": true, "needs_review": false }, { "column_index": 4, "amount": "905786.99", "text": "905 786,99", "confidence": 100, "is_recognized": true, "needs_review": false } ] } ], - "checks": { "relations_failed": [], "soft_relations_failed": [ { "column_index": 1, "relation": "1110 = 11101", "expected": "905786.99", "actual": "900000.00" } ], "duplicate_line_codes": [] } + "checks": { + "relations_failed": [], + "soft_relations_failed": [ { "column_index": 4, "relation": "1110 = 11101", "codes": ["1110", "11101"], "expected": "905786.99", "actual": "900000.00" } ] + } }, { "document_type": "INCOME", "locator": "sheet:Отчет о финансовых результатах", - "header": { "form_name": "Отчет о финансовых результатах", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "report_date_text": "за январь - сентябрь 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710002" }, - "columns": [ { "column_index": 0, "label": "За январь - сентябрь 2025 г.", "period_date": "2025-09-30" } ], - "lines": [ - { "line_code": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "1000.00", "confidence": 100, "is_recognized": true } ] }, - { "line_code": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "-600.00", "confidence": 100, "is_recognized": true } ] }, - { "line_code": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "400.00", "confidence": 100, "is_recognized": true } ] } + "status": "needs_check", + "form_variant": "full", + "warnings": [ + { "code": "INN_NOT_FOUND", "severity": "info", "message": "в шапке не найден ИНН", "field": "inn" } ], - "checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] } + "header": { + "form_name": "Отчет о финансовых результатах", + "organization_name": "ООО \"Пример\"", "organization_name_text": "ООО \"Пример\"", "organization_name_confidence": 100, + "inn": null, "inn_text": null, "inn_confidence": null, + "report_date": "2025-09-30", "report_date_text": "за январь - сентябрь 2025 г.", "report_date_confidence": 100, + "units": "тыс. руб.", "units_text": "в тыс. рублей", "units_confidence": 100, + "okud": "0710002", "okud_text": "0710002", "okud_confidence": 100, + "needs_review_fields": [] + }, + "columns": [ + { "column_index": 3, "label": "За январь - сентябрь 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" } + ], + "lines": [ + { "row_index": 0, "ref": "'Отчет о финансовых результатах'!C12", "line_status": "ok", "line_code": "2110", "line_code_text": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "1000.00", "text": "1 000", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 1, "ref": "'Отчет о финансовых результатах'!C13", "line_status": "detail", "line_code": null, "line_code_text": "", "line_code_confidence": 100, "line_name": "в том числе от продажи продукции", "line_name_confidence": 100, "parent_ref": "'Отчет о финансовых результатах'!C12", "needs_review": false, + "values": [ { "column_index": 3, "amount": "800.00", "text": "800", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 2, "ref": "'Отчет о финансовых результатах'!C14", "line_status": "ok", "line_code": "2120", "line_code_text": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "-600.00", "text": "(600)", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 3, "ref": "'Отчет о финансовых результатах'!C15", "line_status": "ok", "line_code": "2100", "line_code_text": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 3, "amount": "400.00", "text": "400", "confidence": 100, "is_recognized": true, "needs_review": true } ] } + ], + "checks": { + "relations_failed": [ { "column_index": 3, "relation": "2200 = 2100 - 2210 - 2220", "codes": ["2200", "2100", "2210", "2220"], "expected": null, "actual": "400.00" } ], + "soft_relations_failed": [] + } }, { "document_type": "DISCLOSURE", "locator": "sheet:Пояснения", - "header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах" }, + "status": "done", + "form_variant": null, + "warnings": [], + "header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах", "needs_review_fields": [] }, "columns": [], "lines": [] } diff --git a/contracts/examples/result-statement-minimal.json b/contracts/examples/result-statement-minimal.json index 913cfc5..9a3eb1d 100644 --- a/contracts/examples/result-statement-minimal.json +++ b/contracts/examples/result-statement-minimal.json @@ -1,50 +1,51 @@ { - "schema_version": "1.2", + "schema_version": "2.0", "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", - "service": { "name": "scan-gateway", "version": "0.1.0" }, - "status": "done", + "service": { "name": "scan-gateway", "version": "0.2.0", "statements_version": "abc1234" }, + "status": "needs_check", "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", + "metrics": { "duration_ms": 18400, "pages": 2, "warnings": [] }, "documents": [ { "document_type": "BALANCE", - "locator": "pages:1-2", + "locator": "file:8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json;table:0", + "status": "needs_check", + "form_variant": "full", + "warnings": [ + { "code": "ORGANIZATION_UNCERTAIN", "severity": "info", "message": "организация распознана неуверенно", "field": "organization_name" }, + { "code": "ROW_SPLIT", "severity": "info", "message": "строка с несколькими кодами разлеплена: 1370, 1300", "line_refs": ["row:14#1", "row:14#2"] } + ], "header": { "form_name": "Бухгалтерский баланс", - "organization_name": "ООО Ромашка", - "organization_name_confidence": 97.5, - "inn": "7700000000", - "inn_confidence": 99, - "report_date_text": "30 сентября 2025 г.", - "report_date": "2025-09-30", - "units": "тыс. руб." + "organization_name": "ООО Ромашка", "organization_name_text": "ООО Ромашка", "organization_name_confidence": 97.5, + "inn": "7700000000", "inn_text": "7700000000", "inn_confidence": 100, + "report_date": "2025-09-30", "report_date_text": "30 сентября 2025 г.", "report_date_confidence": 100, + "units": "тыс. руб.", "units_text": "тыс. руб.", "units_confidence": 100, + "needs_review_fields": [] }, "columns": [ - { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, - { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + { "column_index": 2, "label": "На 30 сентября 2025 г.", "label_confidence": 100, "period_date": "2025-09-30", "period_kind": "absolute" } ], "lines": [ - { - "line_code": "1100", - "line_code_confidence": 99, - "line_name": "Итого по разделу I", - "line_name_confidence": 96, - "values": [ - { "column_index": 0, "amount": "1234.50", "confidence": 95, "is_recognized": true }, - { "column_index": 1, "amount": "1100", "confidence": 93, "is_recognized": true } - ] - }, - { - "line_code": "1600", - "line_code_confidence": 99, - "line_name": "БАЛАНС (актив)", - "line_name_confidence": 98, - "values": [ - { "column_index": 0, "amount": "5678.00", "confidence": 94.5, "is_recognized": true }, - { "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true } - ] - } - ] + { "row_index": 0, "ref": "row:1", "line_status": "ok", "line_code": "1210", "line_code_text": "1210", "line_code_confidence": 100, "line_name": "Запасы", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "100", "text": "100", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 1, "ref": "row:2", "line_status": "ok", "line_code": "1230", "line_code_text": "1230", "line_code_confidence": 100, "line_name": "Дебиторская задолжен-ность", "line_name_confidence": 80, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428311", "string_value": null, "text": "42831 1", "confidence": 100, "is_recognized": false, "needs_review": false } ] }, + { "row_index": 2, "ref": "row:3", "line_status": "unreadable", "line_code": null, "line_code_text": "ИЧ,250", "line_code_confidence": 25, "line_name": "Финансовые вложения", "line_name_confidence": 67, "needs_review": false, + "values": [ { "column_index": 2, "amount": "50", "text": "50", "confidence": 67, "is_recognized": true, "needs_review": true } ] }, + { "row_index": 3, "ref": "row:4", "line_status": "ok", "line_code": "1200", "line_code_text": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 4, "ref": "row:5", "line_status": "ok", "line_code": "1600", "line_code_text": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] }, + { "row_index": 5, "ref": "row:14#1", "line_status": "ok", "line_code": "1370", "line_code_text": "1370", "line_code_confidence": 100, "line_name": "Нераспределенная прибыль (непокрытый убыток)", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": false, "needs_review": false } ] }, + { "row_index": 6, "ref": "row:14#2", "line_status": "ok", "line_code": "1300", "line_code_text": "1300", "line_code_confidence": 100, "line_name": "Итого по разделу III", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": false, "needs_review": false } ] }, + { "row_index": 7, "ref": "row:15", "line_status": "ok", "line_code": "1700", "line_code_text": "1700", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "needs_review": false, + "values": [ { "column_index": 2, "amount": "428411", "text": "428 411", "confidence": 100, "is_recognized": true, "needs_review": false } ] } + ], + "checks": { "relations_failed": [], "soft_relations_failed": [] } } ] } diff --git a/contracts/examples/v1/result-error.json b/contracts/examples/v1/result-error.json new file mode 100644 index 0000000..54af7cb --- /dev/null +++ b/contracts/examples/v1/result-error.json @@ -0,0 +1,12 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "service": { "name": "tabular-parser", "version": "0.1.0" }, + "status": "error", + "error": { + "code": "NOT_IMPLEMENTED", + "message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet" + }, + "documents": [] +} diff --git a/contracts/examples/v1/result-message-done.json b/contracts/examples/v1/result-message-done.json new file mode 100644 index 0000000..6d56f17 --- /dev/null +++ b/contracts/examples/v1/result-message-done.json @@ -0,0 +1,21 @@ +{ + "schema_version": "1.0", + "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", + "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", + "status": "done", + "service": { "name": "scan-gateway", "version": "0.1.0" }, + "result_s3": { + "bucket": "arm-ka", + "key": "results/c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03/result.json" + }, + "result_schema_version": "1.0", + "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", + "metrics": { + "duration_ms": 18400, + "pages": 2, + "fields_total": 4, + "fields_low_confidence": 0, + "fields_unrecognized": 0, + "warnings": [] + } +} diff --git a/contracts/examples/v1/result-message-error.json b/contracts/examples/v1/result-message-error.json new file mode 100644 index 0000000..f5ec77a --- /dev/null +++ b/contracts/examples/v1/result-message-error.json @@ -0,0 +1,11 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "status": "error", + "service": { "name": "tabular-parser", "version": "0.1.0" }, + "error": { + "code": "NOT_IMPLEMENTED", + "message": "Разбор ОСВ пока не реализован / OSV parsing is not implemented yet" + } +} diff --git a/contracts/examples/v1/result-message-processing.json b/contracts/examples/v1/result-message-processing.json new file mode 100644 index 0000000..2087150 --- /dev/null +++ b/contracts/examples/v1/result-message-processing.json @@ -0,0 +1,7 @@ +{ + "schema_version": "1.0", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "status": "processing", + "service": { "name": "tabular-parser", "version": "0.1.0" } +} diff --git a/contracts/examples/v1/result-osv-minimal.json b/contracts/examples/v1/result-osv-minimal.json new file mode 100644 index 0000000..b0a9e97 --- /dev/null +++ b/contracts/examples/v1/result-osv-minimal.json @@ -0,0 +1,83 @@ +{ + "schema_version": "1.2", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "service": { "name": "tabular-parser", "version": "0.1.0" }, + "status": "done", + "documents": [ + { + "document_type": "OSV", + "locator": "sheet:Лист_1", + "header": { + "organization_name": "ООО Ромашка", + "organization_inn": "7700000000", + "account_code_raw": "60", + "period_text": "1 квартал 2025 г.", + "period_year": 2025, + "period_quarter": "Q1", + "is_cumulative": false, + "period_start": "2025-01-01", + "period_end": "2025-03-31", + "is_from_year_start": true, + "data_type": "BU", + "currency": "RUB", + "header_variant": 1 + }, + "group_schema": [ + { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } + ], + "columns": { + "opening_debit": "C", + "opening_credit": "D", + "turnover_debit": "E", + "turnover_credit": "F", + "closing_debit": "G", + "closing_credit": "H" + }, + "rows": [ + { + "row_number": 1, + "level": 0, + "parent_row_number": null, + "row_value": "60", + "group_type": "ACCOUNT", + "group_name": null, + "account_code_raw": "60", + "counterparty_name": null, + "counterparty_inn": null, + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50" + }, + { + "row_number": 2, + "level": 1, + "parent_row_number": 1, + "row_value": "ООО Лютик, ИНН 7711111111", + "group_type": "COUNTERPARTY", + "group_name": "Контрагенты", + "account_code_raw": "60", + "counterparty_name": "ООО Лютик", + "counterparty_inn": "7711111111", + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50", + "extra": { "КПП": "352801001" } + } + ], + "checks": { + "total_row_matches_root": true, + "balance_identity_violations": [], + "parent_sum_violations": [] + } + } + ] +} diff --git a/contracts/examples/v1/result-osv-needs-check.json b/contracts/examples/v1/result-osv-needs-check.json new file mode 100644 index 0000000..81d64a8 --- /dev/null +++ b/contracts/examples/v1/result-osv-needs-check.json @@ -0,0 +1,125 @@ +{ + "schema_version": "1.1", + "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01", + "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02", + "service": { "name": "tabular-parser", "version": "1.0.0" }, + "status": "needs_check", + "metrics": { + "rows": 3, + "warnings": [ + "sheet=Лист_1 hierarchy=outline", + "§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3" + ] + }, + "documents": [ + { + "document_type": "OSV", + "locator": "sheet:Лист_1", + "header": { + "organization_name": "ООО Ромашка", + "organization_inn": null, + "account_code_raw": "60", + "period_text": "1 квартал 2025 г.", + "period_year": 2025, + "period_quarter": "Q1", + "is_cumulative": false, + "data_type": "BU", + "currency": "RUB", + "header_variant": 1 + }, + "group_schema": [ + { "type": "ACCOUNT", "name": "Счет" }, + { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } + ], + "columns": { + "opening_debit": "D", + "opening_credit": "E", + "turnover_debit": "F", + "turnover_credit": "G", + "closing_debit": "H", + "closing_credit": "I", + "inn": "B" + }, + "rows": [ + { + "row_number": 1, + "level": 0, + "parent_row_number": null, + "row_value": "60", + "group_type": "ACCOUNT", + "group_name": "Счет", + "account_code_raw": "60", + "counterparty_name": null, + "counterparty_inn": null, + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50" + }, + { + "row_number": 2, + "level": 1, + "parent_row_number": 1, + "row_value": "ООО Лютик", + "group_type": "COUNTERPARTY", + "group_name": "Контрагенты", + "account_code_raw": "60", + "counterparty_name": "ООО Лютик", + "counterparty_inn": "7711111111", + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50", + "extra": { "КПП": "352801001" } + }, + { + "row_number": 3, + "level": 2, + "parent_row_number": 2, + "row_value": "Вал.", + "group_type": "OTHER", + "group_name": null, + "account_code_raw": "60", + "counterparty_name": null, + "counterparty_inn": null, + "data_type": "CURRENCY", + "opening_debit": "0.00", + "opening_credit": "400.00", + "turnover_debit": "100.00", + "turnover_credit": "50.00", + "closing_debit": "0.00", + "closing_credit": "400.00" + }, + { + "row_number": 4, + "level": 0, + "parent_row_number": null, + "row_value": "Итого", + "group_type": "TOTAL", + "group_name": null, + "account_code_raw": null, + "counterparty_name": null, + "counterparty_inn": null, + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50" + } + ], + "checks": { + "total_row_matches_root": true, + "balance_identity_violations": [3], + "parent_sum_violations": [] + } + } + ] +} diff --git a/contracts/examples/v1/result-statement-excel.json b/contracts/examples/v1/result-statement-excel.json new file mode 100644 index 0000000..260528b --- /dev/null +++ b/contracts/examples/v1/result-statement-excel.json @@ -0,0 +1,46 @@ +{ + "schema_version": "1.2", + "job_id": "3f1c7a2e-5b64-4d0a-9c1e-8a7b6d5e4f30", + "file_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d", + "service": { "name": "tabular-parser", "version": "0.3.0" }, + "status": "done", + "metrics": { "duration_ms": 412, "rows": 7, "warnings": ["sheet=Бухгалтерский баланс. Лист 1 значений с дробной частью при единице тыс. руб.: 3"] }, + "documents": [ + { + "document_type": "BALANCE", + "locator": "sheet:Бухгалтерский баланс. Лист 1,Бухгалтерский баланс. Лист 2", + "header": { "form_name": "Бухгалтерский баланс", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "inn": "7700000000", "inn_confidence": 100, "report_date_text": "на 30 сентября 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710001", "okpo": "12345678" }, + "columns": [ + { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, + { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + ], + "lines": [ + { "line_code": "1110", "line_code_confidence": 100, "line_name": "Нематериальные активы", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "11101", "line_code_confidence": 100, "line_name": "Нематериальные активы, готовые к использованию", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "900000.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1100", "line_code_confidence": 100, "line_name": "Итого по разделу I", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1200", "line_code_confidence": 100, "line_name": "Итого по разделу II", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": null, "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": null, "confidence": 100, "is_recognized": true } ] }, + { "line_code": "1600", "line_code_confidence": 100, "line_name": "БАЛАНС", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "586260.26", "confidence": 100, "is_recognized": true }, { "column_index": 1, "amount": "905786.99", "confidence": 100, "is_recognized": true } ] } + ], + "checks": { "relations_failed": [], "soft_relations_failed": [ { "column_index": 1, "relation": "1110 = 11101", "expected": "905786.99", "actual": "900000.00" } ], "duplicate_line_codes": [] } + }, + { + "document_type": "INCOME", + "locator": "sheet:Отчет о финансовых результатах", + "header": { "form_name": "Отчет о финансовых результатах", "organization_name": "ООО \"Пример\"", "organization_name_confidence": 100, "report_date_text": "за январь - сентябрь 2025 г.", "report_date": "2025-09-30", "units": "тыс. руб.", "okud": "0710002" }, + "columns": [ { "column_index": 0, "label": "За январь - сентябрь 2025 г.", "period_date": "2025-09-30" } ], + "lines": [ + { "line_code": "2110", "line_code_confidence": 100, "line_name": "Выручка", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "1000.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "2120", "line_code_confidence": 100, "line_name": "Себестоимость продаж", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "-600.00", "confidence": 100, "is_recognized": true } ] }, + { "line_code": "2100", "line_code_confidence": 100, "line_name": "Валовая прибыль (убыток)", "line_name_confidence": 100, "values": [ { "column_index": 0, "amount": "400.00", "confidence": 100, "is_recognized": true } ] } + ], + "checks": { "relations_failed": [], "soft_relations_failed": [], "duplicate_line_codes": [] } + }, + { + "document_type": "DISCLOSURE", + "locator": "sheet:Пояснения", + "header": { "form_name": "Пояснения к бухгалтерскому балансу и отчету о финансовых результатах" }, + "columns": [], + "lines": [] + } + ] +} diff --git a/contracts/examples/v1/result-statement-minimal.json b/contracts/examples/v1/result-statement-minimal.json new file mode 100644 index 0000000..913cfc5 --- /dev/null +++ b/contracts/examples/v1/result-statement-minimal.json @@ -0,0 +1,50 @@ +{ + "schema_version": "1.2", + "job_id": "8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03", + "file_id": "c7e2a9b4-1d58-4630-a2f7-6b4d8e0c1f04", + "service": { "name": "scan-gateway", "version": "0.1.0" }, + "status": "done", + "raw_s3_key": "raw/8d6b1f30-2e94-4a7c-b5d8-0f1e9c3a6b03.json", + "documents": [ + { + "document_type": "BALANCE", + "locator": "pages:1-2", + "header": { + "form_name": "Бухгалтерский баланс", + "organization_name": "ООО Ромашка", + "organization_name_confidence": 97.5, + "inn": "7700000000", + "inn_confidence": 99, + "report_date_text": "30 сентября 2025 г.", + "report_date": "2025-09-30", + "units": "тыс. руб." + }, + "columns": [ + { "column_index": 0, "label": "На 30 сентября 2025 г.", "period_date": "2025-09-30" }, + { "column_index": 1, "label": "На 31 декабря 2024 г.", "period_date": "2024-12-31" } + ], + "lines": [ + { + "line_code": "1100", + "line_code_confidence": 99, + "line_name": "Итого по разделу I", + "line_name_confidence": 96, + "values": [ + { "column_index": 0, "amount": "1234.50", "confidence": 95, "is_recognized": true }, + { "column_index": 1, "amount": "1100", "confidence": 93, "is_recognized": true } + ] + }, + { + "line_code": "1600", + "line_code_confidence": 99, + "line_name": "БАЛАНС (актив)", + "line_name_confidence": 98, + "values": [ + { "column_index": 0, "amount": "5678.00", "confidence": 94.5, "is_recognized": true }, + { "column_index": 1, "amount": "-12.30", "confidence": 90, "is_recognized": true } + ] + } + ] + } + ] +} diff --git a/contracts/recognition-result-message.schema.json b/contracts/recognition-result-message.schema.json index 4d9f401..a78afd8 100644 --- a/contracts/recognition-result-message.schema.json +++ b/contracts/recognition-result-message.schema.json @@ -1,13 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "arm-ka/recognition-result-message/v1", - "title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0", + "$id": "arm-ka/recognition-result-message/v2", + "title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v2.0", "description": "Тело сообщения на subject ответа (настройка окружения NATS_RESULT_SUBJECT, по умолчанию files.tabular_parsed). Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:: — для status = processing; result:: — для done, needs_check и error. Форма запроса : описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).", - "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (2.1) — необязательные поля и новые значения enum; MAJOR (3.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает известный MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения; новый MAJOR — «сначала ядро»: ядро на время перехода принимает конверты (включая ack processing) и прежнего, и нового MAJOR. Changelog: 1.0 — исходный контракт; 2.0 (RSHB/arm-ka-docs#116, change unified-statement-normalization) — metrics.warnings[] стали структурами {code, severity, message, line_refs?, column_index?, field?, locator?} (как metrics.warnings файла результата), производитель пишет в конверт тот же MAJOR, что и в файл результата. Схема 1.0 на окне перехода лежит рядом — recognition-result-message.v1.schema.json (примеры — examples/v1/).", "type": "object", "required": ["schema_version", "job_id", "file_id", "status", "service"], "properties": { - "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, + "schema_version": { "type": "string", "pattern": "^2\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v2.0 пишет \"2.0\"" }, "job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" }, "file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" }, "status": { "enum": ["processing", "done", "needs_check", "error"] }, @@ -47,7 +47,22 @@ "fields_total": { "type": "integer" }, "fields_low_confidence": { "type": "integer" }, "fields_unrecognized": { "type": "integer" }, - "warnings": { "type": "array", "items": { "type": "string" } } + "warnings": { "type": "array", "items": { "$ref": "#/$defs/fileWarning" }, "description": "предупреждения уровня файла — те же, что metrics.warnings файла результата" } + } + } + }, + "$defs": { + "fileWarning": { + "type": "object", + "required": ["code", "severity", "message"], + "properties": { + "code": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$" }, + "severity": { "enum": ["error", "info"] }, + "message": { "type": "string" }, + "line_refs": { "type": "array", "items": { "type": "string" } }, + "column_index": { "type": "integer" }, + "field": { "enum": ["organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address"] }, + "locator": { "type": "string" } } } }, diff --git a/contracts/recognition-result-message.v1.schema.json b/contracts/recognition-result-message.v1.schema.json new file mode 100644 index 0000000..4d9f401 --- /dev/null +++ b/contracts/recognition-result-message.v1.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "arm-ka/recognition-result-message/v1", + "title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0", + "description": "Тело сообщения на subject ответа (настройка окружения NATS_RESULT_SUBJECT, по умолчанию files.tabular_parsed). Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:: — для status = processing; result:: — для done, needs_check и error. Форма запроса : описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", + "type": "object", + "required": ["schema_version", "job_id", "file_id", "status", "service"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" }, + "job_id": { "type": "string", "format": "uuid", "description": "recognition_job.id" }, + "file_id": { "type": "string", "format": "uuid", "description": "stored_file.id" }, + "status": { "enum": ["processing", "done", "needs_check", "error"] }, + "service": { + "type": "object", + "required": ["name", "version"], + "properties": { + "name": { "enum": ["tabular-parser", "scan-gateway"] }, + "version": { "type": "string" } + } + }, + "result_s3": { + "type": "object", + "description": "файл результата (recognition-result): results/{file_id}/{job_id}/result.json", + "required": ["bucket", "key"], + "properties": { + "bucket": { "type": "string", "minLength": 1 }, + "key": { "type": "string", "minLength": 1 } + } + }, + "result_schema_version": { "type": "string", "pattern": "^\\d+\\.\\d+$", "description": "schema_version файла результата, MAJOR.MINOR" }, + "error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { "enum": ["UNSUPPORTED_FORMAT", "UNREADABLE", "TIMEOUT", "SCHEMA_MISMATCH", "NOT_IMPLEMENTED", "FILE_READ_ERROR", "INVALID_FORMAT", "INVALID_DOCUMENT", "RECOGNITION_FAILED", "UNKNOWN_ERROR"] }, + "message": { "type": "string" } + } + }, + "raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" }, + "metrics": { + "type": "object", + "properties": { + "duration_ms": { "type": "integer" }, + "pages": { "type": "integer" }, + "rows": { "type": "integer" }, + "fields_total": { "type": "integer" }, + "fields_low_confidence": { "type": "integer" }, + "fields_unrecognized": { "type": "integer" }, + "warnings": { "type": "array", "items": { "type": "string" } } + } + } + }, + "allOf": [ + { + "if": { + "properties": { "status": { "enum": ["done", "needs_check"] } }, + "required": ["status"] + }, + "then": { "required": ["result_s3", "result_schema_version"] } + }, + { + "if": { + "properties": { "status": { "const": "error" } }, + "required": ["status"] + }, + "then": { "required": ["error"] } + } + ] +} diff --git a/contracts/recognition-result.schema.json b/contracts/recognition-result.schema.json index 43781b3..19531dd 100644 --- a/contracts/recognition-result.schema.json +++ b/contracts/recognition-result.schema.json @@ -1,13 +1,13 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "arm-ka/recognition-result/v1", - "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.2", - "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.", - "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1, 1.2, …) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения.", + "$id": "arm-ka/recognition-result/v2", + "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v2.0", + "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Формы ББ/ОФР нормализуются общим кодом arm_statements (05-domain-rules.md §11): статусы строк, needs_review, проверки §7, статус документа и структурные предупреждения приходят готовыми; ядро хранит их как пришли и пересчитывает тем же кодом только после правок аналитика. Нормализация счетов ОСВ, резолв контрагентов, сверка ИНН и периода — в arm-core.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (2.1, 2.2, …) — необязательные поля и новые значения enum; MAJOR (3.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает известный MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения. Новый MAJOR — порядок «сначала ядро»: ядро на время перехода принимает и прежний, и новый MAJOR (два маппера), сервисы переходят после выкладки ядра. Changelog: 1.0 — исходный контракт; 1.1 — osvDocument.rows[].extra; 1.2 — DISCLOSURE, statementDocument.checks, границы периода ОСВ; 2.0 (RSHB/arm-ka-docs#116, change unified-statement-normalization) — line_code nullable и line_status (ok | unreadable | missing | detail), row_index, ref, parent_ref, line_code_text, needs_review у значения и строки, values[].text, header.<поле>_text и <поле>_confidence у всех полей шапки, header.needs_review_fields, columns[].period_kind и label_confidence, document.status и document.warnings у всех документов (включая ОСВ), statementDocument.form_variant, metrics.warnings и warnings — структуры с уровнем и привязкой, checks обязателен у BALANCE/INCOME со строками, нарушения с codes, удалён checks.duplicate_line_codes, service.statements_version, header.okei (+ _text, _confidence), смысл is_recognized у OCR отвязан от confidence, statementDocument.form_variant обязателен, OTHER удалён из statementDocument.document_type. Схема 1.2 на окне перехода лежит рядом — recognition-result.v1.schema.json (примеры — examples/v1/); удаляется вместе с маппером 1.x ядра после перехода обоих сервисов.", "type": "object", "required": ["schema_version", "job_id", "file_id", "service", "status", "documents"], "properties": { - "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.2 пишет \"1.2\"" }, + "schema_version": { "type": "string", "pattern": "^2\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v2.0 пишет \"2.0\"" }, "job_id": { "type": "string", "format": "uuid" }, "file_id": { "type": "string", "format": "uuid" }, "service": { @@ -15,10 +15,11 @@ "required": ["name", "version"], "properties": { "name": { "enum": ["tabular-parser", "scan-gateway"] }, - "version": { "type": "string" } + "version": { "type": "string" }, + "statements_version": { "type": "string", "minLength": 1, "description": "коммит репозитория arm-statements, из которого разложена копия общего кода; обязателен, если в documents есть statementDocument" } } }, - "status": { "enum": ["done", "needs_check", "error"] }, + "status": { "enum": ["done", "needs_check", "error"], "description": "done ⇔ все документы done и нет предупреждений файла уровня error; error — документов нет" }, "error": { "type": ["object", "null"], "properties": { @@ -35,7 +36,7 @@ "fields_total": { "type": "integer" }, "fields_low_confidence": { "type": "integer" }, "fields_unrecognized": { "type": "integer" }, - "warnings": { "type": "array", "items": { "type": "string" } } + "warnings": { "type": "array", "items": { "$ref": "#/$defs/fileWarning" }, "description": "предупреждения уровня файла (ошибочный json набора ИС, потерянная таблица, формат файла)" } } }, "raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" }, @@ -44,31 +45,68 @@ "items": { "oneOf": [ { "$ref": "#/$defs/osvDocument" }, { "$ref": "#/$defs/statementDocument" } ] } } }, - "if": { - "properties": { "status": { "enum": ["done", "needs_check"] } }, - "required": ["status"] - }, - "then": { - "properties": { "documents": { "minItems": 1 } } - }, + "allOf": [ + { + "if": { + "properties": { "status": { "enum": ["done", "needs_check"] } }, + "required": ["status"] + }, + "then": { + "properties": { "documents": { "minItems": 1 } } + } + }, + { + "if": { + "properties": { "documents": { "contains": { "properties": { "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE"] } }, "required": ["document_type"] } } }, + "required": ["documents"] + }, + "then": { + "properties": { "service": { "required": ["name", "version", "statements_version"] } } + } + } + ], "$defs": { "money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в JSON" }, + "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "documentStatus": { "enum": ["done", "needs_check"] }, + "headerField": { "enum": ["organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address"] }, + "warning": { + "type": "object", + "required": ["code", "severity", "message"], + "properties": { + "code": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]*$", "examples": ["COLUMN_WITHOUT_DATE", "ROW_SPLIT"], "description": "код по таблице 05-domain-rules.md §11; не перечисление — новый код не меняет MAJOR" }, + "severity": { "enum": ["error", "info"], "description": "error → документ needs_check; info на статус не влияет" }, + "message": { "type": "string" }, + "line_refs": { "type": "array", "items": { "type": "string" }, "description": "ref строк документа — только строк из statementDocument.lines[]; строки ролей title / header / numbering в line_refs не попадают (о них — message)" }, + "column_index": { "type": "integer", "description": "столбец сетки кандидата (как columns[].column_index); у COLUMN_SKIPPED и других предупреждений о столбце вне columns[] — номер этого столбца" }, + "field": { "$ref": "#/$defs/headerField" } + } + }, + "fileWarning": { + "allOf": [ { "$ref": "#/$defs/warning" } ], + "properties": { + "locator": { "type": "string", "examples": ["file:aep_3_ofr.json", "sheet:Лист3"], "description": "место в файле, к которому относится предупреждение" } + } + }, "failedRelation": { "type": "object", - "required": ["column_index", "relation", "expected", "actual"], + "required": ["column_index", "relation", "codes", "expected", "actual"], "properties": { "column_index": { "type": "integer" }, "relation": { "type": "string", "examples": ["1600 = 1100 + 1200"] }, - "expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения как в файле" }, - "actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам" } + "codes": { "type": "array", "minItems": 1, "items": { "type": "string", "pattern": "^\\d{4,5}$" }, "description": "коды цели и членов соотношения: нарушение подсвечивает все строки этих кодов в колонке" }, + "expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения (цель) по строкам ok; null — цели нет" }, + "actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам ok" } } }, "osvDocument": { "type": "object", - "required": ["document_type", "locator", "header", "group_schema", "rows"], + "required": ["document_type", "locator", "header", "group_schema", "rows", "status", "warnings"], "properties": { "document_type": { "const": "OSV" }, "locator": { "type": "string", "examples": ["sheet:Лист_1"] }, + "status": { "$ref": "#/$defs/documentStatus", "description": "done — контрольные суммы §1.4 листа сошлись, иначе needs_check (05-domain-rules.md §1.6)" }, + "warnings": { "type": "array", "items": { "$ref": "#/$defs/warning" }, "description": "предупреждения листа ОСВ; все уровня info, код с префиксом OSV_" }, "header": { "type": "object", "required": ["organization_name", "account_code_raw", "period_text"], @@ -142,79 +180,131 @@ } } }, + "statementValue": { + "type": "object", + "required": ["column_index", "amount", "text", "confidence", "is_recognized", "needs_review"], + "properties": { + "column_index": { "type": "integer", "description": "колонка значений — columns[].column_index" }, + "amount": { "$ref": "#/$defs/money" }, + "string_value": { "type": ["string", "null"], "description": "текст ячейки, только когда amount = null и текст непуст и не прочерк" }, + "text": { "type": ["string", "null"], "description": "сырой текст источника всегда (у Excel — как показывает числовой формат); null — позиции нет" }, + "confidence": { "$ref": "#/$defs/confidence", "description": "уверенность источника (у Excel 100; позиции нет — 100)" }, + "is_recognized": { "type": "boolean", "description": "текст разобран как число без выброшенных и исправленных символов; от confidence не зависит" }, + "needs_review": { "type": "boolean", "description": "сводный признак: нуждается в покрытии §7 и не покрыто, не подтверждён знак, или в несошедшемся жёстком соотношении (§11)" } + } + }, + "statementLine": { + "type": "object", + "required": ["row_index", "ref", "line_status", "line_code", "line_code_text", "line_name", "needs_review", "values"], + "properties": { + "row_index": { "type": "integer", "minimum": 0, "description": "порядковый номер строки в документе с 0, по порядку сетки после разлепления" }, + "ref": { "type": "string", "minLength": 1, "examples": ["row:8", "row:14#2", "Баланс!C27", "'Бухгалтерский баланс. Лист 1'!C14"], "description": "положение в источнике; уникален в документе. OCR — row:[#k]; Excel — <лист>! по правилу Excel: имя листа в одинарных кавычках, если в нём есть что-то кроме букв, цифр и _ или оно начинается с цифры; апостроф внутри имени удваивается" }, + "line_status": { "enum": ["ok", "unreadable", "missing", "detail"] }, + "line_code": { "type": ["string", "null"], "pattern": "^\\d{4,5}$", "description": "разобранный код; null ⇔ line_status ≠ ok" }, + "line_code_text": { "type": ["string", "null"], "description": "сырой текст ячейки кода" }, + "line_code_confidence": { "$ref": "#/$defs/confidence", "description": "от источника; у кода с заменой двойника — не выше 99" }, + "line_name": { "type": ["string", "null"], "description": "распознанное наименование как в источнике (не из справочника)" }, + "line_name_confidence": { "$ref": "#/$defs/confidence" }, + "parent_ref": { "type": ["string", "null"], "description": "ref строки-родителя у detail" }, + "needs_review": { "type": "boolean", "description": "неуверенный код не подтверждён структурой или повтор кода с неуверенной копией" }, + "values": { "type": "array", "items": { "$ref": "#/$defs/statementValue" } } + }, + "allOf": [ + { + "if": { "properties": { "line_status": { "const": "ok" } }, "required": ["line_status"] }, + "then": { "properties": { "line_code": { "type": "string" } } }, + "else": { "properties": { "line_code": { "type": "null" } } } + }, + { + "if": { "properties": { "line_status": { "const": "detail" } }, "required": ["line_status"] }, + "then": { "required": ["parent_ref"], "properties": { "parent_ref": { "type": "string" } } } + } + ] + }, "statementDocument": { "type": "object", - "required": ["document_type", "locator", "header", "columns", "lines"], + "required": ["document_type", "form_variant", "locator", "header", "columns", "lines", "status", "warnings"], "properties": { - "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] }, - "locator": { "type": "string", "examples": ["pages:1-2"] }, + "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE"], "description": "OTHER в v2.0 не порождается: лист «прочее» — предупреждение файла SHEET_OTHER, документа нет (значение OTHER — только в схеме 1.x)" }, + "locator": { "type": "string", "examples": ["file:aep_3.json;table:0", "sheet:Баланс,Баланс (2)"] }, + "status": { "$ref": "#/$defs/documentStatus" }, + "form_variant": { "enum": ["full", "simplified", null], "description": "по структуре кодов строк ok (§11); null — DISCLOSURE; обязателен" }, + "warnings": { "type": "array", "items": { "$ref": "#/$defs/warning" } }, "header": { "type": "object", "properties": { - "form_name": { "type": "string" }, + "form_name": { "type": ["string", "null"], "description": "заголовок формы как в источнике" }, "organization_name": { "type": ["string", "null"] }, - "organization_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "inn": { "type": ["string", "null"] }, - "inn_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "report_date_text": { "type": ["string", "null"] }, + "organization_name_text": { "type": ["string", "null"] }, + "organization_name_confidence": { "$ref": "#/$defs/confidence" }, + "inn": { "type": ["string", "null"], "pattern": "^(\\d{10}|\\d{12})$" }, + "inn_text": { "type": ["string", "null"] }, + "inn_confidence": { "$ref": "#/$defs/confidence" }, "report_date": { "type": ["string", "null"], "format": "date" }, + "report_date_text": { "type": ["string", "null"] }, + "report_date_confidence": { "$ref": "#/$defs/confidence" }, "units": { "type": ["string", "null"], "examples": ["тыс. руб."] }, + "units_text": { "type": ["string", "null"] }, + "units_confidence": { "$ref": "#/$defs/confidence" }, "okud": { "type": ["string", "null"] }, + "okud_text": { "type": ["string", "null"] }, + "okud_confidence": { "$ref": "#/$defs/confidence" }, "okpo": { "type": ["string", "null"] }, + "okpo_text": { "type": ["string", "null"] }, + "okpo_confidence": { "$ref": "#/$defs/confidence" }, "okopf": { "type": ["string", "null"] }, + "okopf_text": { "type": ["string", "null"] }, + "okopf_confidence": { "$ref": "#/$defs/confidence" }, "okfs": { "type": ["string", "null"] }, - "address": { "type": ["string", "null"] } + "okfs_text": { "type": ["string", "null"] }, + "okfs_confidence": { "$ref": "#/$defs/confidence" }, + "okei": { "type": ["string", "null"], "examples": ["384"], "description": "код ОКЕИ единицы (384 — тыс. руб., 383 — руб., 385 — млн руб.); запасной источник units (§11.2)" }, + "okei_text": { "type": ["string", "null"] }, + "okei_confidence": { "$ref": "#/$defs/confidence" }, + "address": { "type": ["string", "null"] }, + "address_text": { "type": ["string", "null"] }, + "address_confidence": { "$ref": "#/$defs/confidence" }, + "needs_review_fields": { "type": "array", "items": { "$ref": "#/$defs/headerField" }, "uniqueItems": true, "description": "поля шапки, значение которых требует проверки и держит документ в needs_check (сейчас — только неуверенная дата отчёта, не подтверждённая датами колонок); сомнения в ИНН и организации — предупреждения info с field, сюда не входят (§11.9)" } } }, "columns": { "type": "array", "items": { "type": "object", - "required": ["column_index", "label"], + "required": ["column_index", "label", "period_kind"], "properties": { - "column_index": { "type": "integer" }, - "label": { "type": "string", "examples": ["На 30 сентября 2025 г."] }, - "period_date": { "type": ["string", "null"], "format": "date" } - } - } - }, - "lines": { - "type": "array", - "items": { - "type": "object", - "required": ["line_code", "line_name", "values"], - "properties": { - "line_code": { "type": "string" }, - "line_code_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "line_name": { "type": "string" }, - "line_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "values": { - "type": "array", - "items": { - "type": "object", - "required": ["column_index"], - "properties": { - "column_index": { "type": "integer" }, - "amount": { "$ref": "#/$defs/money" }, - "string_value": { "type": ["string", "null"] }, - "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, - "is_recognized": { "type": "boolean" } - } - } - } + "column_index": { "type": "integer", "minimum": 0, "description": "номер столбца в сетке кандидата шага 1 (у OCR — column_index ИС, не обязательно подряд; у Excel — номер столбца листа первого сегмента, с 0); уникален в документе; колонки сегментов без шапки сопоставляются с колонками первого сегмента по порядку и несут их номер" }, + "label": { "type": "string", "examples": ["На 30 сентября 2025 г.", "На отчетную дату"] }, + "label_confidence": { "$ref": "#/$defs/confidence", "description": "у Excel 100; подписи нет — null" }, + "period_date": { "type": ["string", "null"], "format": "date", "description": "конец периода колонки (period_end); у относительной подписи — от эффективной даты документа" }, + "period_kind": { "enum": ["absolute", "relative:current", "relative:prev_year_end", "relative:prev_prev_year_end", "relative:prev_period", "none"] } } } }, + "lines": { "type": "array", "items": { "$ref": "#/$defs/statementLine" }, "description": "у документа без строк (DISCLOSURE, форма без столбца кодов) — пустой массив" }, "checks": { "type": "object", - "description": "Результаты самопроверки формы по соотношениям §7 (tabular-parser). Отсутствие объекта — «не проверялось» (OCR-путь); пустые массивы — «проверено, сошлось».", + "required": ["relations_failed", "soft_relations_failed"], + "description": "Самопроверка формы по соотношениям §7 общим кодом (оба конвейера). Есть у BALANCE/INCOME со строками, нет у документа без строк. Пустые массивы — «проверено, сошлось».", "properties": { - "relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения жёстких соотношений: документ needs_check" }, - "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения мягких соотношений (OQ-12, подстроки «в том числе») и жёстких в пределах округления итогов (OQ-138): только предупреждение" }, - "duplicate_line_codes": { "type": "array", "items": { "type": "string" }, "description": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" } + "relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "жёсткие соотношения, не сошедшиеся вне допуска ±1 и округления 0,001 %: документ needs_check" }, + "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "мягкие соотношения (OQ-12, подстроки) и жёсткие в пределах округления (OQ-138): info" } } } - } + }, + "allOf": [ + { + "if": { + "properties": { "document_type": { "enum": ["BALANCE", "INCOME"] }, "lines": { "minItems": 1 } }, + "required": ["document_type", "lines"] + }, + "then": { "required": ["checks"] } + }, + { + "if": { "properties": { "lines": { "maxItems": 0 } }, "required": ["lines"] }, + "then": { "not": { "required": ["checks"] } } + } + ] } } } diff --git a/contracts/recognition-result.v1.schema.json b/contracts/recognition-result.v1.schema.json new file mode 100644 index 0000000..43781b3 --- /dev/null +++ b/contracts/recognition-result.v1.schema.json @@ -0,0 +1,220 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "arm-ka/recognition-result/v1", + "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.2", + "description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.", + "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1, 1.2, …) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать новый MINOR), потому что закрытые enum отвергают незнакомые значения.", + "type": "object", + "required": ["schema_version", "job_id", "file_id", "service", "status", "documents"], + "properties": { + "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.2 пишет \"1.2\"" }, + "job_id": { "type": "string", "format": "uuid" }, + "file_id": { "type": "string", "format": "uuid" }, + "service": { + "type": "object", + "required": ["name", "version"], + "properties": { + "name": { "enum": ["tabular-parser", "scan-gateway"] }, + "version": { "type": "string" } + } + }, + "status": { "enum": ["done", "needs_check", "error"] }, + "error": { + "type": ["object", "null"], + "properties": { + "code": { "enum": ["UNSUPPORTED_FORMAT", "UNREADABLE", "TIMEOUT", "SCHEMA_MISMATCH", "NOT_IMPLEMENTED", "FILE_READ_ERROR", "INVALID_FORMAT", "INVALID_DOCUMENT", "RECOGNITION_FAILED", "UNKNOWN_ERROR"] }, + "message": { "type": "string" } + } + }, + "metrics": { + "type": "object", + "properties": { + "duration_ms": { "type": "integer" }, + "pages": { "type": "integer" }, + "rows": { "type": "integer" }, + "fields_total": { "type": "integer" }, + "fields_low_confidence": { "type": "integer" }, + "fields_unrecognized": { "type": "integer" }, + "warnings": { "type": "array", "items": { "type": "string" } } + } + }, + "raw_s3_key": { "type": ["string", "null"], "description": "scan-gateway: raw/{job_id}.json — сырой ответ ИС Сканирование; в БД не регистрируется" }, + "documents": { + "type": "array", + "items": { "oneOf": [ { "$ref": "#/$defs/osvDocument" }, { "$ref": "#/$defs/statementDocument" } ] } + } + }, + "if": { + "properties": { "status": { "enum": ["done", "needs_check"] } }, + "required": ["status"] + }, + "then": { + "properties": { "documents": { "minItems": 1 } } + }, + "$defs": { + "money": { "type": ["string", "null"], "pattern": "^-?\\d+(\\.\\d{1,2})?$", "description": "Decimal как строка, чтобы не терять копейки в JSON" }, + "failedRelation": { + "type": "object", + "required": ["column_index", "relation", "expected", "actual"], + "properties": { + "column_index": { "type": "integer" }, + "relation": { "type": "string", "examples": ["1600 = 1100 + 1200"] }, + "expected": { "$ref": "#/$defs/money", "description": "левая часть соотношения как в файле" }, + "actual": { "$ref": "#/$defs/money", "description": "правая часть, вычисленная по строкам" } + } + }, + "osvDocument": { + "type": "object", + "required": ["document_type", "locator", "header", "group_schema", "rows"], + "properties": { + "document_type": { "const": "OSV" }, + "locator": { "type": "string", "examples": ["sheet:Лист_1"] }, + "header": { + "type": "object", + "required": ["organization_name", "account_code_raw", "period_text"], + "properties": { + "organization_name": { "type": "string", "description": "как в r1 файла" }, + "organization_inn": { "type": ["string", "null"] }, + "account_code_raw": { "type": "string", "examples": ["60", "58.03"] }, + "period_text": { "type": "string", "examples": ["1 квартал 2025 г.", "2024 г."] }, + "period_year": { "type": ["integer", "null"] }, + "period_quarter": { "enum": ["Q1", "Q2", "Q3", "Q4", null] }, + "is_cumulative": { "type": ["boolean", "null"], "description": "по тексту; ядро может переопределить по данным (OQ-02)" }, + "period_start": { "type": ["string", "null"], "format": "date", "description": "начало периода по шапке: первое число квартала/года либо начало диапазона месяцев (OQ-131)" }, + "period_end": { "type": ["string", "null"], "format": "date", "description": "конец периода по шапке" }, + "is_from_year_start": { "type": ["boolean", "null"], "description": "period_start — 1 января; is_cumulative не переопределяет" }, + "data_type": { "enum": ["BU", "NU", "SUM", "CURRENCY", "QUANTITY", null] }, + "currency": { "type": ["string", "null"], "examples": ["RUB", "руб."] }, + "header_variant": { "enum": [1, 2] } + } + }, + "group_schema": { + "type": "array", + "description": "порядок группировок из шапки, после первого ACCOUNT", + "items": { + "type": "object", + "required": ["type"], + "properties": { + "type": { "enum": ["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"] }, + "name": { "type": "string", "description": "исходный текст из шапки" }, + "has_inn": { "type": "boolean" } + } + } + }, + "columns": { + "type": "object", + "description": "диагностика: какие колонки листа были приняты за шесть сумм и ИНН", + "additionalProperties": { "type": "string" } + }, + "rows": { + "type": "array", + "items": { + "type": "object", + "required": ["row_number", "level", "row_value", "group_type"], + "properties": { + "row_number": { "type": "integer" }, + "level": { "type": "integer" }, + "parent_row_number": { "type": ["integer", "null"] }, + "row_value": { "type": "string" }, + "group_type": { "enum": ["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER", "TOTAL"] }, + "group_name": { "type": ["string", "null"] }, + "account_code_raw": { "type": ["string", "null"], "description": "код текущей ветки, как в файле (60.1)" }, + "counterparty_name": { "type": ["string", "null"] }, + "counterparty_inn": { "type": ["string", "null"] }, + "data_type": { "enum": ["BU", "NU", "SUM", "CURRENCY", "QUANTITY", null] }, + "opening_debit": { "$ref": "#/$defs/money" }, + "opening_credit": { "$ref": "#/$defs/money" }, + "turnover_debit": { "$ref": "#/$defs/money" }, + "turnover_credit": { "$ref": "#/$defs/money" }, + "closing_debit": { "$ref": "#/$defs/money" }, + "closing_credit": { "$ref": "#/$defs/money" }, + "extra": { "type": ["object", "null"], "description": "нераспознанные колонки внутри таблицы: заголовок колонки → текст ячейки (§1.2)", "additionalProperties": { "type": ["string", "null"] } } + } + } + }, + "checks": { + "type": "object", + "properties": { + "total_row_matches_root": { "type": "boolean" }, + "balance_identity_violations": { "type": "array", "items": { "type": "integer" }, "description": "row_number строк, где opening−turnover≠closing" }, + "parent_sum_violations": { "type": "array", "items": { "type": "integer" } } + } + } + } + }, + "statementDocument": { + "type": "object", + "required": ["document_type", "locator", "header", "columns", "lines"], + "properties": { + "document_type": { "enum": ["BALANCE", "INCOME", "DISCLOSURE", "OTHER"] }, + "locator": { "type": "string", "examples": ["pages:1-2"] }, + "header": { + "type": "object", + "properties": { + "form_name": { "type": "string" }, + "organization_name": { "type": ["string", "null"] }, + "organization_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "inn": { "type": ["string", "null"] }, + "inn_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "report_date_text": { "type": ["string", "null"] }, + "report_date": { "type": ["string", "null"], "format": "date" }, + "units": { "type": ["string", "null"], "examples": ["тыс. руб."] }, + "okud": { "type": ["string", "null"] }, + "okpo": { "type": ["string", "null"] }, + "okopf": { "type": ["string", "null"] }, + "okfs": { "type": ["string", "null"] }, + "address": { "type": ["string", "null"] } + } + }, + "columns": { + "type": "array", + "items": { + "type": "object", + "required": ["column_index", "label"], + "properties": { + "column_index": { "type": "integer" }, + "label": { "type": "string", "examples": ["На 30 сентября 2025 г."] }, + "period_date": { "type": ["string", "null"], "format": "date" } + } + } + }, + "lines": { + "type": "array", + "items": { + "type": "object", + "required": ["line_code", "line_name", "values"], + "properties": { + "line_code": { "type": "string" }, + "line_code_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "line_name": { "type": "string" }, + "line_name_confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "values": { + "type": "array", + "items": { + "type": "object", + "required": ["column_index"], + "properties": { + "column_index": { "type": "integer" }, + "amount": { "$ref": "#/$defs/money" }, + "string_value": { "type": ["string", "null"] }, + "confidence": { "type": ["number", "null"], "minimum": 0, "maximum": 100 }, + "is_recognized": { "type": "boolean" } + } + } + } + } + } + }, + "checks": { + "type": "object", + "description": "Результаты самопроверки формы по соотношениям §7 (tabular-parser). Отсутствие объекта — «не проверялось» (OCR-путь); пустые массивы — «проверено, сошлось».", + "properties": { + "relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения жёстких соотношений: документ needs_check" }, + "soft_relations_failed": { "type": "array", "items": { "$ref": "#/$defs/failedRelation" }, "description": "нарушения мягких соотношений (OQ-12, подстроки «в том числе») и жёстких в пределах округления итогов (OQ-138): только предупреждение" }, + "duplicate_line_codes": { "type": "array", "items": { "type": "string" }, "description": "коды строк, встретившиеся в документе больше одного раза (осталась первая строка)" } + } + } + } + } + } +} diff --git a/contracts/statement-code-orders.yaml b/contracts/statement-code-orders.yaml new file mode 100644 index 0000000..de9bce1 --- /dev/null +++ b/contracts/statement-code-orders.yaml @@ -0,0 +1,194 @@ +# Упорядоченные перечни кодов строк форм ББ/ОФР по редакциям и вариантам — данные для структурного подтверждения +# неуверенного кода (05-domain-rules.md §11.9 «Код строки»; change unified-statement-normalization, K-5, Р-20). +# +# Что это: порядок кодов бланка (итоги после слагаемых: 1110 … 1190, 1100), без наименований (Р-3: «код → наименование» +# знает только ядро, arm-core/api/rules/statement_lines.yaml). Только четырёхзначные коды: пятизначная подстрока +# подтверждается через родителя (§11.9). Перечень — объединение кодов шаблонов банка (словарь имён ядра, +# fixtures/statements-xlsx/templates/) и кодов печатных форм 1С и сканов, найденных в fixtures (источник — комментарий +# у кода). Код без образца в fixtures сюда не добавляется: кандидаты — OQ-168. +# +# Где живёт: этот файл — источник; в пакете arm_statements лежит его байтовая копия (rules/statement_code_orders.yaml), +# идентичность проверяет sync-statements --check (как копии схем контракта в сервисах). Ядро берёт порядок и состав +# редакций из копии пакета; sync-statements --check сверяет, что все коды словаря имён ядра есть в перечне своей +# редакции. Правка перечня — PR сюда, затем в arm-statements, затем sync-statements в потребителях («сначала ядро»). +# +# Как читается (§11.9): четырёхзначные коды строк ok документа — последовательность; для каждой редакции берётся +# наибольшая подпоследовательность, согласованная с порядком перечня варианта формы документа (form_variant); выбирается +# редакция с более длинной (при равенстве — по эффективной дате: fsbu4 после 2024-12-31). Повтор кода допустим только +# там, где он есть в перечне (repeats): тогда структура подтверждает обе копии; повтор вне перечня с неуверенной копией — +# needs_review у неуверенной копии (Р-16, Р-20). +# +# Проверка формы файла — scripts/validate_contracts.py (make validate-contracts). +version: 1 + +# Законные повторы кода в одном перечне (Р-20). Ключ — редакция и форма; значение — коды, которые встречаются в +# перечне больше одного раза: в варианте full код стоит ровно 1 + (число его вхождений в этот список) раз, в simplified — +# не чаще (сейчас повторов в simplified нет; validate_contracts.py проверяет оба правила). +repeats: + 66n: + # раздел III для некоммерческих организаций («Целевое финансирование») печатается после раздела III коммерческих + # (fixtures/ocr/aep_3.json, 31.12.2024: 1310 «Паевой фонд», 1320 «Целевой капитал», 1350 «Целевые средства», + # 1360 «Фонд недвижимого и особо ценного движимого имущества», 1370 «Резервный и иные целевые фонды») + BALANCE: ["1310", "1320", "1350", "1360", "1370", "1300"] + INCOME: [] + fsbu4: + # печатная форма 1С по ФСБУ 4/2023 (fixtures/ocr/aep_6.json, «БО 1 кв 2025.json», «Бухгалтерская отчетность + # 1 кв.25.json»): 1220 «НДС, начисленный по отгрузке», 1520 «Обязательство по аренде» (AURORA-1431) + BALANCE: ["1220", "1520"] + # 2410 «Налог при упрощенной системе налогообложения» после 2412 (fixtures/ocr/aep_6.json, OQ-152) + INCOME: ["2410"] + +editions: + 66n: + BALANCE: + full: + - "1110" + - "1120" + - "1130" + - "1140" + - "1150" + - "1160" + - "1170" + - "1180" + - "1190" + - "1100" + - "1210" + - "1220" + - "1230" + - "1231" # код клиента: unagrande-group/2024-FY/ББ. Лист 1 .xls + - "1240" + - "1250" + - "1260" + - "1200" + - "1600" + - "1310" + - "1320" + - "1340" + - "1350" + - "1360" + - "1370" + - "1300" + - "1310" # раздел III НКО (aep_3), см. repeats + - "1320" + - "1350" + - "1360" + - "1370" + - "1300" + - "1410" + - "1420" + - "1430" + - "1450" + - "1400" + - "1510" + - "1520" + - "1530" + - "1540" + - "1550" + - "1500" + - "1700" + # упрощённая форма, приказ 66н прил. 5 — по бланку, образца в fixtures нет (OQ-166) + simplified: ["1150", "1170", "1210", "1250", "1230", "1600", "1300", "1350", "1360", "1410", "1450", "1510", + "1520", "1550", "1700"] + INCOME: + full: + - "2110" + - "2120" + - "2100" + - "2210" + - "2220" + - "2200" + - "2310" + - "2320" + - "2330" + - "2340" + - "2350" + - "2300" + - "2410" + - "2411" + - "2412" + - "2460" + - "2400" + - "2510" + - "2520" + - "2530" # печатная форма 1С: unagrande-co/2024-FY/ОФР. Лист 2 .xls, mezheninovskaya/2025-Q3 + - "2500" + - "2900" + - "2910" + - "2930" # печатная форма 1С: fixtures/ocr/aep_3.json (после 2900) + # упрощённая форма, приказ 66н прил. 5 — по бланку, образца нет (OQ-166) + simplified: ["2110", "2120", "2330", "2340", "2350", "2410", "2400"] + fsbu4: + BALANCE: + full: + - "1105" # печатная форма 1С: npz/2025-Q3, fixtures/ocr/aep_6.json, «БО 1 кв 2025.json» + - "1110" + - "1130" # печатная форма 1С: npz/2025-Q3, «БО 1 кв 2025.json», «Бухгалтерская отчетность 1 кв.25.json» + - "1140" # то же и aep_6.json + - "1150" + - "1160" + - "1170" + - "1180" + - "1190" + - "1100" + - "1210" + - "1215" + - "1220" + - "1220" # «НДС, начисленный по отгрузке», см. repeats + - "1230" + - "1240" + - "1250" + - "1260" + - "1200" + - "1600" + - "1310" + - "1320" + - "1340" + - "1350" + - "1360" + - "1370" + - "1300" + - "1410" + - "1420" + - "1430" + - "1450" + - "1400" + - "1510" + - "1520" + - "1520" # «Обязательство по аренде», см. repeats + - "1530" # печатная форма 1С: npz/2025-Q3, aep_6.json, оба json 1 кв. 2025 + - "1540" + - "1550" + - "1500" + - "1700" + # упрощённая форма по ФСБУ 4/2023 — состав принят как у 66н до образца (OQ-166, OQ-168) + simplified: ["1150", "1170", "1210", "1250", "1230", "1600", "1300", "1350", "1360", "1410", "1450", "1510", + "1520", "1550", "1700"] + INCOME: + full: + - "2110" + - "2120" + - "2100" + - "2210" + - "2220" + - "2200" + - "2310" + - "2320" + - "2330" + - "2340" + - "2350" + - "2300" + - "2410" + - "2411" + - "2412" + - "2410" # «Налог при упрощенной системе налогообложения», см. repeats + - "2420" + - "2460" # печатная форма 1С: npz/2025-Q3, aep_6.json, оба json 1 кв. 2025 + - "2400" + - "2510" + - "2520" + - "2530" + - "2500" + - "2900" + - "2910" + # упрощённая форма по ФСБУ 4/2023 — как у 66н до образца (OQ-166, OQ-168) + simplified: ["2110", "2120", "2330", "2340", "2350", "2410", "2400"] diff --git a/dev/checks.md b/dev/checks.md index afd55eb..e053180 100644 --- a/dev/checks.md +++ b/dev/checks.md @@ -21,7 +21,7 @@ | `make test` | `pytest -m "not integration"`: юнит-тесты; **включает тест идентичности вендоренного контракта** `api/contracts/` с `../docs/contracts` | нет | | `make test-integration` | `pytest -m integration` против инфраструктуры compose (PostgreSQL 16, NATS, MinIO; бакет `arm-ka` должен существовать, его создаёт `make up`) | да, `make up` | | `make check` | `make lint` затем `make test`; останавливается на первой упавшей проверке (только при последовательном запуске: `lint` и `test` — пререквизиты, при `make -j` они идут параллельно) | нет | -| `make check-full` | `make check` + `make test-integration` + сборка docker-образа (`docker build`); для `arm-core` — расширенный набор, см. ниже | да, `make up` и Docker | +| `make check-full` | `make check` + `make test-integration` + сборка docker-образа (`docker build`) + `statements-check` (свежесть копии общего кода `arm_statements`, см. ниже); для `arm-core` — расширенный набор, см. ниже | да, `make up` и Docker | | `make up` | поднимает инфраструктуру из `../docs/dev/docker-compose.yml`; постоянные сервисы `postgres`, `nats`, `minio` | Docker | Сводка шагов `make check`: `ruff check .` → `ruff format --check .` → `mypy src` (в `arm-core` — `mypy src scripts`) → @@ -51,13 +51,60 @@ docker compose -f ../docs/dev/docker-compose.yml --project-name arm-ka run --rm | Репозиторий | Шаги по порядку | |---|---| -| `scan-gateway` | `check` → `test-integration` → `docker-build` (образ `scan-gateway:dev`); интеграционный тест поднимает эмулятор ИС Сканирование в процессе теста | -| `tabular-parser` | `check` → `test-integration` → `docker-build` (образ `tabular-parser`) | -| `arm-core` | `check` → `dbml-check` → `check-infra` → `test-integration` → `docker-build` → `web-check` | +| `scan-gateway` | `check` → `statements-check` → `test-integration` → `docker-build` (образ `scan-gateway:dev`); интеграционный тест поднимает эмулятор ИС Сканирование в процессе теста | +| `tabular-parser` | `check` → `statements-check` → `test-integration` → `docker-build` (образ `tabular-parser`) | +| `arm-core` | `check` → `statements-check` → `dbml-check` → `check-infra` → `test-integration` → `docker-build` → `web-check` | + +`statements-check` появляется в сервисе вместе с копией пакета (PR подключения хоста); до этого цели нет. + +### Копия общего кода `arm_statements` и `sync-statements` + +Общий код нормализации ББ/ОФР (`05-domain-rules.md` §11) живёт в репозитории `arm-statements` пакетом `arm_statements/`; +в `arm-core`, `tabular-parser` и `scan-gateway` он лежит **копией** — прямые правки копии запрещены (Р-1, design +`openspec/changes/unified-statement-normalization/design.md`). Скрипт и цели docs (`make -C docs sync-statements`, +`statements-check`, `test-sync-statements`) — arm-ka-docs#120; `statements-check` сервисов появляется с PR подключения +копии в каждом хосте (план 2, задачи 7–9) — до шага 3 выкладки. Раздел описывает поведение по спеке +`statement-normalization` (требование «`sync-statements`…»). + +- **Где копия:** `api/arm_statements/` (рядом с `api/src/`; импорт `import arm_statements` — `api/` в `sys.path`, как для + `src`). Пакет приносит свои тесты (`api/arm_statements/tests/`); конфиг линтеров пакета в копию не кладётся — хост держит + `arm_statements` в строгом блоке `mypy` и в `testpaths` (P2-1, P2-3); `ruff`, `pytest` и `mypy` хоста + охватывают копию: `ruff` и `pytest` — по каталогу `api/` (тесты пакета подключены как `testpaths`), `mypy` — `MYPY_PATHS` + хоста дополняется `arm_statements` (`mypy src arm_statements`, в `arm-core` — `mypy src scripts arm_statements`). +- **Маркер версии:** `api/arm_statements/VERSION` — полный хэш коммита `arm-statements`, из которого разложена копия; + пишет только скрипт. Сервис при старте читает его и пишет в результат `service.statements_version`; ядро сравнивает его + с `statements_version` задания при пересчёте после правки (`STATEMENTS_VERSION_MISMATCH`, `info`). +- **Скрипт:** `docs/scripts/sync-statements.sh` (по образцу `sync-web.sh`), запуск из workspace или `make -C docs + sync-statements SYNC_ARGS=…`. Источник — локальный checkout `arm-statements` рядом (`../arm-statements`) или + `--source `, ссылка — `origin/main` или `--ref `; хост — `--host arm-core|tabular-parser|scan-gateway` или + `--host-dir `. Без `--check` — раскладывает копию нужного коммита в новый worktree хоста на ветке фичи и пишет + `VERSION`; issue и PR — как у `sync-web.sh`; `--dry-run` — то же без issue, проверок, коммита и PR. С `--in-place` — + раскладывает копию в рабочее дерево существующего worktree хоста без git-операций (подключение хоста; PR фичи, + которой нужна новая версия пакета; при прямой правке копии или недостающей зависимости — отказ `20`, ничего не + пишется). Обзор по трём хостам рядом — `make -C docs statements-check`; сборка снимка для контура + (`prepare-contour-snapshot.sh`) вызывает `--check` для хостов, где копия уже есть (Р-42). +- **`--check`, коды возврата** (файл `VERSION` в сравнении не участвует — в источнике его нет): `0` — копия равна дереву + `arm_statements/` коммита из `VERSION`, это коммит `--ref`, и копия перечня кодов равна docs; `10` — копия равна дереву + коммита из `VERSION`, но в `--ref` есть более новый (копия отстала), или байтовая копия перечня кодов + `arm_statements/rules/statement_code_orders.yaml` отличается от `docs/contracts/statement-code-orders.yaml` (перечень в + docs правился раньше `arm-statements` или пересинхронизации хоста) — предупреждение, не ошибка; `20` — копия отличается + от дерева коммита из `VERSION` (прямая правка), `VERSION` нет в истории источника, зависимости пакета (список + `dependencies` его `pyproject`) отсутствуют в зависимостях хоста, либо (только `arm-core`) код словаря имён + `api/rules/statement_lines.yaml` отсутствует в перечне своей редакции копии пакета; `2` — нет checkout + `arm-statements` или хоста, неизвестный аргумент. Файлы расхождения перечисляются в выводе. Порядок правки перечня + кодов: PR в docs → PR в `arm-statements` → `sync-statements` в хостах («сначала ядро»); до конца цепочки хосты получают + `10`. +- **Конфиг линтеров и тесты пакета:** пакет проходит самый строгий конфиг `ruff` / `mypy` среди хостов (в своём + репозитории), потому что конфиг хоста применяется к копии, а править копию нельзя; тесты пакета — со своим + `conftest.py`, `autouse`-фикстуры `conftest` хоста ограничены каталогом `tests/` хоста. +- **`statements-check` в `make check-full`:** `../docs/scripts/sync-statements.sh --check --host-dir .` (docs — `../docs` + или `ARM_KA_DOCS`); код `0` и `10` — цель зелёная (при `10` предупреждение печатается, в описание PR вписывается, что копия + отстала), `20` и `2` — цель красная. В `make check` и в пайплайн платформы не входит: тест, читающий соседний + репозиторий, уже ломал `main` (arm-core #33). Для `arm-core` дополнительно: -- `dbml-check` — `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.18.dbml` (путь от `api/`, по умолчанию — переменная `DBML` Makefile, она указывает на текущую версию модели; +- `dbml-check` — `python scripts/check_dbml_vs_orm.py ../../docs/model/data-model-v5.19.dbml` (путь от `api/`, по умолчанию — переменная `DBML` Makefile, она указывает на текущую версию модели; другой — `make dbml-check DBML=<путь относительно api/>`): ORM обязана совпадать с DBML; расхождения печатаются построчно, код 1. - Цикл миграций «`alembic upgrade head` → `alembic downgrade base` → `alembic upgrade head`» на чистой БД, пустой @@ -130,8 +177,8 @@ PR убедитесь, что `docs` и `fixtures` доступны (пропу | Цель | Что делает | |---|---| -| `make venv` | `.venv` (Python 3.11, `uv`) с `jsonschema` — единственной зависимостью скриптов | -| `make validate-contracts` | `python scripts/validate_contracts.py`: примеры `contracts/examples/` против трёх схем JSON Schema 2020-12 (3 схемы, 8 примеров); код 1 при любой ошибке | +| `make venv` | `.venv` (Python 3.11, `uv`) с `jsonschema` и `PyYAML` — зависимостями скриптов (после обновления репозитория — перезапустить) | +| `make validate-contracts` | `python scripts/validate_contracts.py`: примеры `contracts/examples/` против текущих схем JSON Schema 2020-12, примеры `contracts/examples/v1/` — против схем прежнего MAJOR `*.v1.schema.json` (окно перехода на контракт 2.0), форма упорядоченных перечней кодов `contracts/statement-code-orders.yaml` (коды — 4 цифры формы; в варианте `full` код стоит 1 + (вхождений в `repeats`) раз, в `simplified` — не чаще); код 1 при любой ошибке | | `make validate-openspec` | `openspec validate slice-0-skeleton --strict` (другое изменение: `make validate-openspec CHANGE=`) | | `make check` | все проверки выше и `test-snapshot`, `test-sync-web`, `test-sync-statements` | | `make test-sync-statements` | сценарии `sync-statements.sh` на синтетических репозиториях: коды `--check`, `--in-place`, `--dry-run`, отказ при прямой правке | @@ -140,7 +187,15 @@ PR убедитесь, что `docs` и `fixtures` доступны (пропу Без `make`: `python scripts/validate_contracts.py` (нужен `jsonschema`) и `openspec validate --strict`. Менять `contracts/**` нужно осознанно: байтовые копии схем лежат в `api/contracts/` каждого сервиса, после правки их -нужно пересинхронизировать (`cp`), иначе тест идентичности в сервисах упадёт. +нужно пересинхронизировать (`cp`), иначе тест идентичности в сервисах упадёт. Правило теста идентичности (у ядра и +сервисов одно, верно до, во время и после окна перехода между MAJOR — сейчас 1 → 2): схема копии с `$id` `…/v` +сверяется с `<имя>.v.schema.json`, **если такой файл есть в docs**, иначе с основным (у `recognition-request` файла +`.v1` нет — копия v1.0 сверяется с основным); пример `examples/<имя>.json` копии — с `examples/v/<имя>.json`, если он +есть, иначе с `examples/<имя>.json` (`M` — MAJOR копии схемы этого вида, вид — по префиксу имени); прочие файлы копии +(`*.v.schema.json`, `examples/v/` у ядра) — с одноимёнными; файлы docs другого MAJOR сервис не сверяет. Правило +вводится во все три репозитория до слияния контракта v2.0 в docs (шаг 0 выкладки; спека `recognition-contract`, +требование «Копия контракта в сервисе идентична источнику»). Ядро держит копии обоих MAJOR. Правило реализовано файлом `api/tests/contract_identity.py`, побайтно одинаковым в трёх репозиториях; правка — сразу во всех трёх. Перечень кодов `contracts/statement-code-orders.yaml` +копируется не в сервисы, а в пакет `arm_statements` (сверяет `sync-statements --check`). ## Порядок перед PR @@ -179,6 +234,6 @@ PR убедитесь, что `docs` и `fixtures` доступны (пропу гарантированы и при `make -j`); в `arm-core` и `tabular-parser` — пререквизитами (при последовательном запуске эквивалентно, при `make -j` порядок не гарантирован). Запускайте без `-j`. - **`ARM_KA_DOCS`/`ARM_KA_FIXTURES`.** `arm-core` учитывает обе переменные; `tabular-parser` — только `ARM_KA_DOCS` - (тестов с маркером `fixtures` пока нет); `scan-gateway` — ни одну (ищет `../docs` и `../fixtures/ocr` по фиксированному - пути; тексты причин пропуска не содержат «ARM_KA_FIXTURES недоступен»). Пока расхождение не устранено, для - `scan-gateway` docs/fixtures нужно класть рядом. + (тестов с маркером `fixtures` пока нет); `scan-gateway` учитывает `ARM_KA_DOCS` в тесте идентичности контракта (с шага 0 + #116), а `ARM_KA_FIXTURES` — нет (ищет `../fixtures/ocr` по фиксированному пути; тексты причин пропуска не содержат + «ARM_KA_FIXTURES недоступен»). Пока расхождение по fixtures не устранено, для `scan-gateway` fixtures нужно класть рядом. diff --git a/dev/frontend-api-guide.md b/dev/frontend-api-guide.md index 08d4f98..b4aa97d 100644 --- a/dev/frontend-api-guide.md +++ b/dev/frontend-api-guide.md @@ -411,6 +411,98 @@ UI прячет элементы по `GET /me → permissions` (объедин - `cross_form` зависит от второго документа ЮЛ за тот же период: появилась ОФР отдельным файлом — перечитайте ББ. - Документ формы появляется после того, как файл стал `PROCESSED`; до этого `…/statement` — 404. +### 5.6.1 Формы ББ/ОФР после единой нормализации — **после реализации** + +> **Пока не работает на `main`.** Раздел описывает API после change `unified-statement-normalization` +> (RSHB/arm-ka-docs#116; контракт распознавания v2.0, DBML v5.19; дочерние PR arm-core и arm-web ещё не созданы). +> Правила — `context/05-domain-rules.md` §7, §9.3, §9.5 пп. 2, 4, 9, §11; экран — спека `arm-web` того же change. +> В `…/statement` поля добавляются, старые не удаляются: клиент, написанный по §5.6, продолжит читать ответ. +> **Исключение — карточка файла:** `FileOut.job.warnings` меняет тип со строк на объекты (ниже, «Карточка файла»), это +> несовместимо — экран файлов обновляется в той же выкладке, что и ядро. После слияния PR раздел переедет в §5.6 и §1. + +**`GET /documents/{id}/statement` — новые поля `StatementOut`:** + +- `document.form_variant` — `full` | `simplified` | `null` (упрощённая форма — свой набор проверок §7). +- `header.needs_review_fields[]` — поля шапки, которые держат документ в «Требует проверки» (сейчас бывает только + `report_date`; подсветить поле). Сомнения в ИНН и организации — не здесь, а в `issues` с `field` (`INN_UNCERTAIN`, + `ORGANIZATION_UNCERTAIN`, уровень `info`). `header.source` — `{<поле>: {text, confidence}}`: как поле распознано + (сырой текст и уверенность; `confidence` — строка Decimal `"61.54"` или `null`, как прочие уверенности) — в + подсказку у поля; `header.organization_name_modified` — наименование правилось; `header.okei` — код ОКЕИ (только + чтение). +- `columns[].period_kind` — `absolute` | `relative:current` | `relative:prev_year_end` | `relative:prev_prev_year_end` | + `relative:prev_period` | `none`; `columns[].label_confidence`. Колонка с `period_kind = none` и `period_date = null` — + нужна дата от аналитика (ввод даты у колонки). +- `lines[]` — **все строки источника**, а не только строки с кодом: `row_index` (порядок в файле; `null` — и только + тогда — у строк, созданных правкой, и у достроенных шаблонных; `origin = template` ⇔ `row_index = null`), `source_ref`, `line_status` (`ok` | `unreadable` | `missing` | `detail`), `line_code` (`null` у + статусов кроме `ok`), `line_code_text` (сырой текст ячейки кода — показывать серым у `unreadable` / `missing`), + `line_code_confidence` (строка Decimal или `null`; подтверждение кода её не меняет), `line_name` (наименование **из + файла**), `reference_name` (справочное имя по коду или `null` — его показывать основным, + файловое рядом, если отличается), `parent_row_index` (родитель расшифровки `detail`), `needs_review` (подсветить строку), + `is_confirmed`, `line_code_modified`. **Один код может стоять у нескольких строк** (повтор кода законен): не строить + словарь «код → строка», ключ строки — `id` (у достроенных шаблонных — `line_code`, их у кода с реальной строкой `ok` нет). +- `values[]` — `text` (сырой текст источника; в подсказку ячейки вместе с `confidence`), `needs_review`, `is_confirmed`. + **Подсветка ячейки — только по `needs_review`**; по `confidence < 100` и `is_recognized = false` больше не подсвечивать + (сервис уже свёл их с проверками §7: догадка, подтверждённая сошедшимся соотношением, не подсвечивается). У значений + строк `detail` `needs_review` всегда `false` (расшифровки статус не держат) — подсказку с `text` и уверенностью + показывать и у них. +- `issues[]` — предупреждения и нарушения документа: `{id, kind: "warning" | "relation" | "soft", code, severity: "error" | + "info", message, line_ids[], column_id?, column_index?, field?, source: "service" | "core", relation?, codes?, expected?, + actual?}`; показывать у места (строки, ячейки, поле шапки) или у документа; `error` отличать от `info`. `column_index` без + `column_id` — столбец, которого нет среди колонок формы (пропущенная колонка «Изменение, %»): показывать у документа. + Нарушения §7 приходят и здесь (`kind = relation` / `soft`, `code = RELATION_FAILED` / `RELATION_SOFT`) — у любой формы, + в том числе загруженной до перехода. +- `checks.failed[]` / `soft_failed[]` — у нарушения ещё `codes[]` и `line_ids[]` (все строки кодов соотношения). +- `edition_lines[] {code, name}` — справочник строк формы для подсказки кода при назначении: коды редакции формы по + одному (имя — первое из словаря), у формы без даты (редакции нет) — коды всех редакций по одному. +- Порядок `lines` — по возрастанию кода, как сейчас (одинаковые коды — по `row_index`); `unreadable` / `missing` — после + ближайшей предыдущей по `row_index` строки с кодом (без такой — в начале), `detail` — сразу после родителя. Разделы и + размещение итогов «как на бумаге» строит экран, как и раньше (`formLayout`). + Расшифровки (`detail`) по умолчанию скрывать за переключателем «Показать расшифровки (N)». + +**`PATCH /documents/{id}/statement` — новые части тела** (можно вместе в одном запросе; один запрос — один пересчёт): + +| Часть | Тело | Эффект | Ошибки | +|---|---|---|---| +| подтвердить ячейку / снять подтверждение | `values: [{line_id, column_id, confirmed: true \| false}]` | `is_confirmed`, `needs_review = false` (при `false` — пересчитывается), сумма не меняется, акцепт не сбрасывается | 422 `UNKNOWN_CELL` | +| дата колонки | `columns: [{column_id, period_date}]` (дата обязательна) | дата колонки, `is_current`, период документа, `period_kind = absolute` (правка даты отчёта её больше не пересчитывает); снимает `COLUMN_WITHOUT_DATE` / `COLUMN_DATE_UNCERTAIN`; та же дата у колонки с таким предупреждением — подтверждение (акцепт не сбрасывает) | 422 `UNKNOWN_COLUMN`; `period_date: null` — 422 массивом | +| назначить / сменить код | `lines: [{line_id, line_code}]` | `unreadable` / `missing` / `detail` / `ok` → `ok` с этим кодом, `line_code_confidence = 100` | 422 `INVALID_LINE_CODE` (не 4–5 цифр), 409 `LINE_CODE_TAKEN` (код занят строкой, созданной правкой, с суммами — сначала очистить её) | +| отметить расшифровкой | `lines: [{line_id, line_status: "detail"}]` | строка выходит из сумм и не блокирует подтверждение; обратно — только назначением кода | 422 `LINE_CODE_REQUIRED` (`line_status: "ok"` — в `ok` переводит только `line_code`) | +| подтвердить код / снять подтверждение | `lines: [{line_id, confirmed: true \| false}]` | `is_confirmed`, `needs_review` строки = `false` (при `false` — пересчитывается); `line_code_confidence` и `line_code_modified` не меняются — в подсказке остаётся исходная уверенность | — | +| наименование организации | `header: {organization_name}` (непустая строка) | `organization_name_modified`, `counterparty_check`; снимает `NO_ORGANIZATION_NO_INN` | 422 массивом | +| подтвердить дату отчёта | `header: {report_date}` тем же значением, если `report_date` в `needs_review_fields` | поле уходит из `needs_review_fields`, снимается `REPORT_DATE_UNCERTAIN` | — | + +Общие ошибки `lines`: 422 `UNKNOWN_LINE` (строка не этого документа), 422 `INVALID_LINE_PATCH` (в элементе нет действия или +их несколько). У ошибок элемента `lines[]` (`LINE_CODE_TAKEN`, `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, +`INVALID_LINE_PATCH`, `UNKNOWN_LINE`) в `detail` есть `line_id` и `line_code` первого неверного элемента — показывать +ошибку у поля этой строки. Правка суммы по `line_code` (достроенная строка) для кода, у которого уже есть строка `ok`, — 422 +`UNKNOWN_CELL`: передайте `line_id`. Правка `header.report_date` пересчитывает даты колонок с `period_kind = relative:*`. +Удалять строки нельзя — для этого «Сделать расшифровкой». Акцепт проверок сбрасывает правка данных (сумма, код, статус +строки, дата колонки, поле шапки), подтверждения (`confirmed`) его не сбрасывают. Ответ — `StatementOut` после пересчёта. + +**`POST /documents/{id}/confirm` для формы — порядок 409:** `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` → +`DOCUMENT_NOT_CONFIRMABLE` → `PERIOD_REQUIRED` → **`LINES_UNRESOLVED`** (`detail.lines[] {line_id, row_index, +line_code_text}` — строки `unreadable` / `missing`, у которых в ячейках есть сумма или нераспознанный текст; кнопку +«Подтвердить данные» отключать заранее с подсказкой «Разберите строки без кода: N») → **`DUPLICATE_FORM`** +(`detail.document_id` — в этом файле уже подтверждена форма того же типа, года и квартала) → `CHECKS_NOT_PASSED`. + +**Статус документа.** Форма из OCR может стать `RECOGNIZED` (и сразу автоподтвердиться), если сервис всё подтвердил +проверками: сомнительные ячейки расшифровок (`detail`) статус не держат, поэтому чистые сканы 1С (например, ББ и ОФР за +1 кв. 2025 в fixtures) могут прийти `RECOGNIZED`. `NEEDS_CHECK` остаётся, когда есть что смотреть: подсвеченные ячейки и +строки, `issues` уровня `error`, неразобранные строки. Две формы одного типа и периода в одном файле приходят двумя +документами; ни одна не автоподтверждается. + +**Карточка файла и документа (несовместимое изменение).** `FileOut.job.warnings` — список объектов `{code, severity: +"error" | "info", message, locator}` вместо строк (у заданий до выкладки ядро отдаёт строки, обёрнутые в `{code: +"LEGACY_WARNING", severity: "info", message, locator: null}`); показывать `message` и `locator`, `error` отличать от +`info`. `FileOut.job.mapping_warnings` — по-прежнему строки. `FileOut.documents[].issue_counts` и +`DocumentOut.issue_counts` — `{error, info}`: сколько записей `issues` у документа (для значка в панели файлов без +запроса документа). `DocumentOut.issues[]` — те же записи, что `issues` формы, +у любого документа, в том числе ОСВ (предупреждения листа `OSV_*`); `DocumentOut.warnings` (строки маппинга) остаётся на +окно перехода. + +**ГСК/ГСЗ.** `crm_id` в `POST …/related-groups` и `PATCH /directories/related-groups/{id}` сохраняется и возвращается в +`RelatedGroupOut` / `RelatedGroupDetail`; повтор — 409 `CRM_ID_EXISTS` (arm-core#112, DBML v5.19). + ### 5.7 Консолидации | Метод и путь | Право | Запрос | Ответ | Ошибки | @@ -606,6 +698,10 @@ async function download(path: string, fallbackName: string): Promise { | `ALREADY_CONFIRMED`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_CONFIRMABLE` | 409 | подтверждение, правка формы | — | перечитать документ | | `PERIOD_REQUIRED` | 409 | подтверждение формы | — | «Укажите дату отчёта» → режим правки шапки | | `CHECKS_NOT_PASSED` | 409 | подтверждение формы | `failures[] {column_index, relation, expected, actual}` | показать нарушения, предложить акцепт | +| `LINES_UNRESOLVED` | 409 | подтверждение формы (после #116, §5.6.1) | `lines[] {line_id, row_index, line_code_text}` | перейти к первой строке без кода, предложить назначить код или «Это расшифровка» | +| `DUPLICATE_FORM` | 409 | подтверждение формы (после #116) | `document_id` | ссылка на уже подтверждённую форму того же типа и периода в файле | +| `LINE_CODE_TAKEN` | 409 | правка формы, назначение кода (после #116) | — | ошибка поля кода: код занят строкой, созданной правкой, с суммами | +| `INVALID_LINE_CODE`, `LINE_CODE_REQUIRED`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`, `UNKNOWN_COLUMN` | 422 | правка формы `lines` / `columns` (после #116) | — | ошибка поля / ошибка клиента (перечитать форму) | | `DOCUMENT_NOT_STATEMENT` | 409 | `…/statement` не по форме ББ/ОФР | — | не открывать экран [41] для такого документа | | `DOCUMENT_READONLY` | 409 | правка/акцепт подтверждённой формы | — | перевести экран в просмотр | | `UNKNOWN_CELL`, `DUPLICATE_CELL` | 422 | правка формы | — | ошибка клиента (перечитать форму); `UNKNOWN_CELL` также: `line_code` уже есть в форме (слать `line_id`), код вне справочника редакции, нет редакции | @@ -657,6 +753,7 @@ async function download(path: string, fallbackName: string): Promise { | Задача | Что поменяется для фронта | Как подготовиться | |---|---|---| | OQ-54 — SSO РАИСА | Меняется только получение токена (вместо dev-формы) | держать слой получения токена изолированным (`frontend-auth-guide.md` §9) | +| arm-ka-docs#116 — единая нормализация ББ/ОФР (контракт v2.0, DBML v5.19) | `GET/PATCH …/statement`: строки со статусами и повторами кода, `needs_review`, `issues[]`, `lines`/`columns`/`confirmed` в PATCH; подтверждение — 409 `LINES_UNRESOLVED`, `DUPLICATE_FORM`; `crm_id` ГСК/ГСЗ сохраняется; **`FileOut.job.warnings` — объекты вместо строк**, `DocumentOut.issues[]` | §5.6.1; не держать словарь «код → строка», подсветку строить по `needs_review`; тип `JobBrief.warnings` в `types/api.ts` поменять вместе с выкладкой ядра | Состояние этих задач — в Gitea (`RSHB/arm-core`, `RSHB/arm-ka-docs`); после слияния каждой этот раздел переезжает в §1. diff --git a/model/data-model-v5.19.dbml b/model/data-model-v5.19.dbml new file mode 100644 index 0000000..5ecf73f --- /dev/null +++ b/model/data-model-v5.19.dbml @@ -0,0 +1,1506 @@ +// ДКБ · Модель данных v5.19: справочники, файлы/распознавание, консолидация, КФО, аудит, ЕХФД +// Формат: DBML (dbdiagram.io). Вставить на https://dbdiagram.io/d +// >>> Источник истины по модели данных. Аналитика в Confluence ссылается сюда. +// Над каждой таблицей — блок «ИСТОЧНИК»: пункт ТЗ АРМ КА BIQ-34 / ТЗ ЕХФД (помечено «ЕХ ТЗ») / Методика; +// страница аналитики (md); экран макета dkb_24.09.26; версия модели, в которой таблица появилась (v4 = исходная DBML аналитики). +// +// v5.19 (решения владельца 17.10.2026, Р-1…Р-19; RSHB/arm-ka-docs#116, openspec/changes/unified-statement-normalization; +// контракт результата v2.0; одна миграция arm-core): +// 1. financial_statement_line: снята уникальность (meta_id, line_code) — повтор кода законен, хранятся все строки (Р-4); +// line_code остаётся nullable и пуст у строк unreadable / missing / detail (Р-8, отменяет OQ-36 / OQ-141). +// Новые колонки: row_index (порядок строки в документе; у шаблонных и созданных правкой — NULL), source_ref +// (положение в источнике, частичный уникальный индекс (meta_id, source_ref) WHERE source_ref IS NOT NULL), +// line_status (enum statement_line_status), line_code_text (сырой текст ячейки кода), parent_row_index (родитель +// detail), needs_review (как пришло от сервиса, пересчитывается ядром после правок), is_confirmed (код подтверждён +// аналитиком). +// 2. financial_statement_value: source_text (values[].text — сырой текст источника), needs_review (как пришло, +// пересчитывается после правок; пересматривает п. 4 v5.13 «считается, не хранится»), is_confirmed + confirmed_by / +// confirmed_at (подтверждено аналитиком без изменения значения). +// 3. financial_statement_column: period_kind (absolute | relative:<вид> | none), label_confidence. +// 4. financial_statement_meta: form_variant (enum statement_form_variant), needs_review_fields (поля шапки с +// needs_review, jsonb-массив имён). +// 5. Новая таблица document_issue (+ enum'ы document_issue_kind, issue_severity, issue_source): предупреждения и нарушения +// §7 документа как пришли от сервиса или посчитаны ядром; очищается при перезагрузке файла; при пересчёте после +// правки записи relation / soft заменяются целиком, warning источника service сохраняются, кроме снимаемых правкой. +// 6. recognition_job.statements_version — коммит копии общего кода arm_statements, которым посчитан результат. +// 7. document: уникальность (stored_file_id, document_type, osv_account_code) — только для типов кроме BALANCE / INCOME +// (частичный индекс; ОСВ по счёту, DISCLOSURE, OTHER — «один на тип на файл», §9.5 п. 2); вторая форма того же типа +// в файле хранится (Р-10, Р-15); новая колонка file_ordinal — индекс документа в documents[] результата (у всех +// документов при любом MAJOR контракта; несозданный документ номер не занимает), уникален в файле. +// 8. related_group.crm_id (varchar, nullable, unique) — ID ГСК/ГСЗ в ЕЦП CRM (AURORA-1619, arm-core#112, OQ-33). +// 9. Признак «строка создана правкой аналитика» — financial_statement_line.row_index IS NULL (заменяет прежний +// признак line_code_original_value IS NULL: у строк unreadable / missing / detail источника кода тоже нет). +// line_code_original_value — код строки из результата как пришёл (NULL у статусов кроме ok и у строк, созданных +// правкой); правками не меняется. +// 10. financial_statement_meta: header_source (jsonb — сырой текст и уверенность всех полей шапки результата, +// {"<поле>": {"text", "confidence"}}), organization_name_original_value и organization_name_modified (наименование +// правится PATCH шапки, как ИНН). +// 11. document_issue: codes (jsonb — коды цели и членов нарушения §7), column_index (столбец предупреждения вне колонок +// формы — COLUMN_SKIPPED и др.). +// Перед выкладкой база очищается от форм ББ/ОФР (решение владельца 18.10.2026, Р-24; скрипт ядра +// scripts/purge_statements.py): миграция только добавляет колонки, таблицу, enum'ы, индексы и значения по умолчанию, +// §7 по существующим формам не считает. Заполнение (миграция проходит и на непустой базе): row_index строк — NULL у +// строк с line_code_original_value IS NULL (созданных правкой), у остальных — по возрастанию line_code внутри формы с 0; +// line_status = ok, needs_review строк = false, needs_review значений = НЕ values_ok (values_ok — правлено или +// is_recognized не false и confidence 100 / NULL), source_text = string_value, is_confirmed = false, period_kind = +// absolute при period_date, иначе none, form_variant = NULL (набор §7 — полный), header_source — из *_original_value +// и inn_confidence, organization_name_original_value = organization_name, file_ordinal — по (created_at, id) внутри +// файла с 0 (у всех документов), related_group.crm_id = NULL; document_issue — пуст: корректность нарушений у форм, +// оставшихся в неочищенной базе, не гарантируется до первой правки. +// Откат гарантирован на пустой БД и на данных, которые v5.18 представляет без потери первичных данных и ручного ввода; +// downgrade останавливается с ошибкой и ничего не меняет при повторе кода в форме, строке без кода, двух формах одного +// типа в файле, непустом related_group.crm_id, organization_name_modified = true; производные и сигнальные колонки +// v5.19 и document_issue удаляет с записью числа удалённых значений в журнал миграции. +// +// v5.18 (решения владельца 09.10.2026; RSHB/arm-ka-docs#90, openspec/changes/console-files): +// 1. stored_file.consolidation_id (uuid, nullable, FK → consolidation): консолидация (ГК + год + квартал), в контексте +// которой загружен файл, — основа «рабочего стола консоли» (полный состав стола — context/05-domain-rules.md §9.5 п. 11), общего для всех версий периода. NULL — загрузка без контекста +// (страница ЮЛ, монозаёмщики, API). Пишется один раз при загрузке (POST /consolidations/{id}/legal-entities/{le}/files); +// при автозамене новый файл без контекста наследует consolidation_id заменённого. Индекс (legal_entity_id, consolidation_id). +// Что использует конкретная версия, по-прежнему определяет consolidation_document; заморозка после VERIFIED — привязки и ex_snapshot. +// Миграция 0006 arm-core: backfill по consolidation_document (при нескольких периодах — самый ранний). +// 2. Note recognition_job.reply_deadline: срок ответа OCR — 20 мин (OQ-159, решение владельца 09.10.2026), структура не меняется. +// +// v5.17 (решение владельца 07.10.2026, пересмотр OQ-31; RSHB/arm-ka-docs#53): +// 1. legal_entity.kpp и legal_entity_history.kpp (varchar, nullable): КПП юридического лица. Формат — 9 символов: +// 4 цифры, 2 символа (цифра или латинская A–Z), 3 цифры; иначе INVALID_KPP. У ИП КПП запрещён (INVALID_KPP; +// импорт — строка пропускается). Для ЮЛ необязателен: существующие записи и импорт без колонки КПП остаются валидными. +// Уникальность — только legal_entity.inn (КПП — атрибут, сопоставление контрагентов ОСВ по ИНН не меняется). +// Изменение КПП — обычная правка со снимком в legal_entity_history. Отменяет п. 2 v5.14 («КПП не вводится»). +// +// v5.16 (Срез 4, решения владельца 06.10.2026): +// 1. Новый enum counterparty_match_kind и колонка osv_entry.counterparty_match_kind (nullable): способ, которым строке ОСВ +// сопоставлен контрагент. NULL ⇔ counterparty_id NULL; INN / NAME / ALIAS — шаг автоматического резолва при маппинге +// (ИНН; точное имя; нормализованное имя или алиас); MANUAL — ручное сопоставление аналитиком; PROPAGATED — +// автоприменение по имени в версии (DOC-221278 п. 12–13). Миграция 0004 заполняет колонку по уже смапленным строкам. +// 2. Без изменений структуры, уточнение семантики consolidation_document: строка создаётся при подтверждении документа +// (по документам ЮЛ — членов периметра версии «в работе»), при подходящем подтверждённом документе — при добавлении ЮЛ +// в периметр; удаляется при отвязке документа (замена файла, удаление, исключение ЮЛ из периметра). Для одной +// (ЮЛ, document_type, osv_account_code, период, признак нарастающего) привязан один документ — самый поздний confirmed_at. +// 3. Без изменений структуры, уточнение семантики consolidation_lock: блокировка без истечения по времени — активна, пока её не +// снял владелец, администратор или перехват другим пользователем с CONSOLIDATION_EDIT по причине (OQ-119). +// +// v5.15 (Срез 2c, решения владельца 05.10.2026): +// 1. osv_data_type: добавлено NU — строки налогового учёта из ОСВ «БУ, НУ» хранятся в osv_entry как есть (OQ-45); +// контракт результата допускал NU с v1.0. +// 2. app_user.email хранится в нижнем регистре: нормализация при записи (seed, администрирование), поиск по +// lower(email) остаётся; уникальность — существующий UNIQUE по email (OQ-59). Структура не меняется. +// 3. Без изменений структуры, уточнение семантики: stored_file.status — PROCESSING ставится inbox'ом по ack сервиса, +// PROCESSED/ERROR — циклом маппинга результата (OQ-67); маппинг выполняется отдельно от inbox (OQ-69); +// recognition_job.group_schema заполняет маппер; «Итого» в osv_entry не хранится (OQ-01). +// 4. Индексы для удаления файла и обхода дерева строк: osv_entry (parent_entry_id), osv_entry (recognition_job_id), +// stored_file (current_recognition_job_id) — проверки FK при DELETE документа/файла и очистке перед повторным +// маппингом шли seq scan'ом (OQ-117). Замер: DELETE документа 1000 строк 8,6 с → 16 мс на 200 тыс. строк. +// +// v5.14 (Срез 1, решения владельца 04.10.2026): +// 1. ЮЛ может состоять ОДНОВРЕМЕННО в одной ГСК и одной ГСЗ (но только в одной группе каждого вида) — отменяет +// ANL-030/v5.8. В related_group_member возвращена денормализованная колонка kind (= related_group.kind, +// равенство проверяет ядро при записи); частичный уникальный индекс стал (legal_entity_id, kind) WHERE is_current. +// 2. КПП у ЮЛ не вводится (OQ-31: поле есть только в макете; решение владельца — не нужен). [Отменено в v5.17 — КПП введён.] +// 3. Без изменений структуры, уточнение семантики: company_group.crm_id = «iD ГК» выгрузки ЕЦП CRM +// (docs/source/biq/ecp-export-lagal-entity.xlsx), base_regional_office — «Базовый РФ ГК в CRM»; флаги «входит в ГСК/ГСЗ» +// выгрузки → членство в одной неявной ГСК/ГСЗ на ГК (правила импорта — 05-domain-rules.md §13). +// 4. audit_action: добавлены LEGAL_ENTITY_CHANGE (создание/изменение/закрытие/перевод ЮЛ, алиасы) и USER_CHANGE +// (создание/деактивация пользователя администратором) — ТЗ 8.2 «журнал изменений справочников» их требует, +// в v5.13 были только GROUP_CHANGE / MEMBERSHIP_CHANGE / USER_ROLE_CHANGE. +// +// v5.13 (ревью 30.09–01.10, замечания 1–7; п.2 parent_entry_id — отложен): +// 1. Права как данные: permission + role_permission (матрица доступов ТЗ сноска 29 — в БД, не в коде); +// права только через роли, прямых назначений пользователю нет. Отменяет решение v5.4. +// 3. osv_group_schema убрана — схема группировок шапки = recognition_job.group_schema jsonb; +// тип строки — osv_entry.group_type. +// 4. financial_statement_value.needs_review убран — производное от confidence/is_recognized, считается запросом. +// 5. recognition_job: оставлен только result_s3_object_id; сырой ответ ИС Сканирование в БД не регистрируется +// (scan-gateway пишет его в S3 по конвенции raw/{job_id}.json для разбора инцидентов). +// 6. stored_file.fields_modified убран — COUNT(is_modified) по данным документа. +// 6-бис. Разделение «файл / документ»: stored_file = физический файл (один на загрузку), document = логическая +// единица (ББ, ОФР, ОСВ по счёту, расшифровка) — один файл содержит N документов (PDF с ББ+ОФР, xlsx с +// несколькими ОСВ листами). Чек-лист, проверки периода/контрагента, подтверждение, FK разобранных данных, +// consolidation_document и ex_snapshot_item — на document. Замена (superseded_by) остаётся на файле целиком. +// 7. file_type: убран P7. +// +// Изменения относительно v4 (кратко): +// FIX — исправлены дефекты v4 (PK у meta, типы, копипаст-note про «контрагента», row_type, parent_entry_id) +// ADD — audit_event, status_history, company_group_assignment, elimination_rule, statement_check_result, +// consolidated_osv_line, chess_cell, vgo_pair_match (агрегат по ТЗ 6.4), ofr_vgo_match, kfo_result_*, +// client_kfo_line, comment, export_log/preset, ex_snapshot*, correction_version +// CHG — role → 7 ролей ТЗ + сегмент (ЗО); consolidation_version — сценарий/подход/гудвилл/решение УОБ; +// stored_file — счёт ОСВ, версии/замена; osv_entry — признак контрагента + override; legal_entity — монозаёмщик +// +// Ключи: у ВСЕХ сущностей суррогатный PK (uuid). Натуральные ключи — UNIQUE-колонки. +// (Для высокообъёмных таблиц — osv_entry, audit_event — uuid можно заменить на bigint identity.) +// ЮЛ и ГК — SCD Type 4 (живая таблица + *_history). Членство в ГСК/ГСЗ — SCD Type 2 (is_current). +// Первичка не мутируется: правки распознанных значений = поле + *_original_value + is_modified + audit_event. +// Производные объекты версии (сводная ОСВ, шахматка, КФО) — snapshot на consolidation_version: +// пересчитываются пока версия «в работе», замораживаются при VERIFIED (ТЗ 6.10, 7.2). +// +// DECISION-1 (ТЗ 6.4 vs v4): первичная модель сопоставления ВГО — АГРЕГАТ по паре компаний × правилу пары счетов +// (vgo_pair_match), как в ТЗ/Прил. 3. Строковое 1:1 сопоставление (vgo_match) сохранено как детализация агрегата. +// DECISION-2 (ЕХФД): ЕХ — те же таблицы + ex_snapshot как «замороженный указатель» на состав верифицированной версии. +// Отдельная физическая БД для ЕХ не предполагается; архив в data lake — выгрузка снапшота. +// DECISION-3: ГО — обычная запись regional_office с is_head_office=true (вместо NULL). +// DECISION-5 (производные консолидации): сводная ОСВ, пары ВГО и шахматка НЕ хранятся, пока версия в работе — +// считаются запросом из osv_entry (актуальные прогоны) × consolidation_member × elimination_rule. +// Признак контрагента ГСК/ГК/Внешний — функция от osv_entry.counterparty_id и периметра версии; ручное действие +// аналитика = сопоставление контрагента с ЮЛ (counterparty_id + алиас), комментарий — в audit_event. +// v5.16: способ сопоставления контрагента (osv_entry.counterparty_match_kind) ХРАНИТСЯ; пробелы квартальной склейки и сам свод — нет. +// При VERIFIED производные замораживаются один раз: структурированный снапшот — jsonb в ex_snapshot_item.payload +// (просмотр в ЛК ЕХ), подготовленный xlsx для выгрузки — s3_object. Отдельная NoSQL-база не вводится. +// Корректировки и гудвилл ссылаются на пару ВГО по естественному ключу (правило, компания 1, компания 2). +// DECISION-4 (распознавание): два отдельных stateless-сервиса без доступа к БД — +// tabular-parser (XLS/XLSX/XLSB) и scan-gateway (адаптер ИС Сканирование для PDF/PNG/JPG). +// Запрос: NATS recognition.{tabular|image}.request + файл в S3 uploads/. Результат: JSON в S3 results/ + +// NATS recognition.result. Единственный писатель в БД — arm-core; корреляция по recognition_job.id; +// идемпотентность по msg_id; outbox при создании recognition_job; DLQ. +// v5.13: результат одного файла содержит N документов; ядро создаёт document при маппинге результата. +// +// v5.1 (по макету dkb_24.09.26): consolidation_lock (взятие в работу), participant_disclosure (расшифровки участника), +// legal_entity_history.event_type, statement_meta реквизиты (ОКПО/ОКОПФ/ОКФС/адрес), kfo_result_line: vgo/share, +// client_kfo_line: ВГО клиента/банка + причина, audit_event: section/result/user_agent, статусы REJECTED/ARCHIVED, +// document_check (проверка периода/контрагента файла), company_group.crm_id nullable (ANL-037). +// v5.2 (DECISION-4): recognition_job: subject/msg_id/deadline/external_task_id/result_schema_version/error_code, статусы timeout/dlq. +// v5.12 (ревью part09): ЕХ самодостаточен — срез не ссылается FK на консолидацию и её результаты (их удаляют по сроку, +// срез хранится «вечно»): ex_snapshot.source_version_id/no без FK; ex_snapshot_item = stored_file_id (исходники храним) +// ИЛИ payload jsonb (сводная ОСВ, ВГО, шахматка, КФО, гудвилл, корректировки); убраны object_type/object_id, +// prepared_s3_object_id (готовые файлы не храним), ex_snapshot.source_system, audit_event.s3_object_id; +// добавлен ex_snapshot.superseded_by_snapshot_id (архивный → актуальный). +// v5.11: consolidation_version.elimination_rules_version — какая версия правил Прил. 3/4 применялась (объяснимость и +// воспроизводимость расчёта замороженной версии). +// v5.10 (DECISION-5, ревью part08): убраны consolidated_osv_line, consolidated_osv_source, vgo_pair_match, chess_sheet, chess_cell, +// osv_entry.counterparty_type (выводится); correction ссылается на пару по (rule_id, company_1_id, company_2_id); +// ex_snapshot_item получил payload jsonb. +// v5.9 (ревью part06/07): statement_check_result убрана — проверки считаются на лету, акцепт КА — на документе +// (financial_statement_meta.checks_accepted_*); reviewed_by/at перенесены в meta (документ ревьюится целиком); +// financial_statement_line: убраны ordinal/row_kind/section (порядок и группирующие строки — шаблон формы на фронте); +// consolidation: company_group_id NOT NULL, related_group_id nullable — ГСК фиксируется при VERIFIED (ANL-029); +// consolidation_version: убран package_uuid (документы в ИС Сканирование по одному, ключ = recognition_job.id), добавлен updated_by. +// v5.8 (ревью part01): убраны company_group_assignment (видимость ТЗ 8.1 выводится из company_group.base_regional_office_id, +// legal_entity.regional_office_id, app_user.regional_office_id; история перезакрепления — company_group_history), +// legal_entity.is_mono_borrower (монозаёмщик = company_group_id IS NULL), related_group_member.kind и индекс related_group(id, kind) +// (после ANL-030 partial-unique по legal_entity_id без условия на kind). +// v5.7: метрики качества распознавания — recognition_job.fields_total/low_confidence/unrecognized (что пришло от сервиса). +// (stored_file.fields_modified/fields_reviewed и financial_statement_value.needs_review — убраны в v5.9/v5.13 как производные.) +// v5.6 (ревизия 28.09, решения по каждой убранной таблице): +// account_code_alias — нормализация кодов в коде; редкие субсчета клиентов добавляются в chart_of_account +// vat_rate — константы; средневзвешенная ставка хранится в vgo_pair_match +// crm_import_batch — импорт справочников только на старте (миграция); след в audit_event(DIRECTORY_IMPORT) + файл в s3_object +// osv_entry_edit — audit_event(RECOGNIZED_DATA_EDIT) + osv_entry.original_values/modified_by/modified_at +// document_check — колонки period_check/counterparty_check (с v5.13 — на document); сверка с периодом версии вычисляется +// export_preset — выбор колонок в момент экспорта, без сохранения наборов +// export_log — audit_event(EXPORT, after_value={report_code, format, columns, rows}); файлы выгрузок не хранятся (S3 lifecycle exports/ = 7 дней) +// v5.5: убрана correction_version — история корректировок (сноска 22) ведётся audit_event(before/after), version_no в correction. +// v5.4: возвращён s3_object (реестр объектов S3 — lifecycle/ретеншн ТЗ 8.4, sha256, единая точка ключей); permission/role_permission — матрица в коде (ОТМЕНЕНО в v5.13). +// v5.3 РЕВИЗИЯ по правилу «в базе только то, что есть в ТЗ или на экране макета». Удалено 14 таблиц: +// - account_code_alias: нормализация кодов (60.1=60.01) — алгоритм в коде (ARCH-004), не справочник +// - vat_rate: ставки 0/10/20/22 — константы; средневзвешенная ставка хранится в vgo_pair_match.vat_weighted_rate +// - disclosure_item: расшифровки участника: в UI скрыты, в ТЗ только тип документа «Расшифровка» → вне scope до решения ANL-040 +// - participant_disclosure: см. disclosure_item +// - crm_import_batch: журнал импортов не в ТЗ/UI; факт импорта и ошибки — audit_event(GROUP_CHANGE/IMPORT) +// - permission / role_permission: ВОЗВРАЩЕНЫ в v5.13 (замечание 1) +// - integration_message: РЕШЕНИЕ — обмен с NATS завязан только на recognition_job (status queued→published = outbox; result_msg_id UNIQUE = inbox/дедуп); отдельная таблица появится, только если возникнет второй тип сообщений +// - osv_entry_edit: история правок сумм ОСВ — audit_event(RECOGNIZED_DATA_EDIT, before/after) +// - document_check: результат проверки периода/контрагента — две колонки (с v5.13 — в document) +// - vgo_match: строковое сопоставление 1:1 — только в аналитике, нет ни в ТЗ (агрегат по Прил. 3), ни в UI; детализация агрегата — consolidated_osv_source +// - export_preset: сохранённые пресеты колонок не в ТЗ/UI (выбор столбцов — при экспорте, без сохранения) +// - export_log: история выгрузок (ТЗ 8.2, ЕХ 6.6) — audit_event(EXPORT) с составом в after_value + +// ============ ENUMS ============ +Enum entity_type { + "ЮЛ" + "ИП" +} + +Enum related_group_kind { + gsk + gsz +} + +Enum account_kind { + A [note: 'актив'] + P [note: 'пассив'] + AP [note: 'активно-пассивный'] +} + +Enum account_row_type { + account [note: 'синтетический счёт, напр. 60'] + subaccount [note: 'субсчёт, напр. 60.01'] + analytic [note: 'подсубсчёт, напр. 60.01.1 / 76.07.1'] +} + +Enum business_segment { + DKB [note: 'крупный бизнес'] + DMSB [note: 'малый и средний'] + DM [note: 'микробизнес'] +} + +Enum document_type { + BALANCE [note: 'Бухгалтерский баланс (0710001)'] + INCOME [note: 'ОФР (0710002)'] + OSV [note: 'ОСВ по счёту — osv_account_code обязателен'] + DISCLOSURE [note: 'Расшифровка'] + OTHER [note: 'Прочий документ'] +} + +Enum file_type { + XLS + XLSX + XLSB + PDF + PNG + JPG +} + +Enum recognition_pipeline { + tabular [note: 'XLS/XLSX/XLSB — tabular-parser'] + image [note: 'PDF/PNG/JPG — ИС Сканирование через scan-gateway'] +} + +Enum period_quarter { + Q1 + Q2 + Q3 + Q4 +} + +// v5.13: статус ФАЙЛА — только техническая обработка. Проверка и подтверждение аналитиком — на document. +Enum file_status { + UPLOADED [note: 'Загружен: поступил, в очереди'] + PROCESSING [note: 'Обработка: распознавание/парсинг'] + PROCESSED [note: 'Результат получен, документы созданы'] + ERROR [note: 'Ошибка: нечитаемый файл, требуется повторная загрузка'] + SUPERSEDED [note: 'Заменён новой версией файла (ТЗ 8.2) — вместе со всеми документами в нём'] +} + +// v5.13: статус ДОКУМЕНТА (ANL-003: «Требуется проверка» при confidence < 100% или непройденных проверках; +// «Подтвержден» — финальный после проверки аналитиком) +Enum document_status { + RECOGNIZED [note: 'данные получены, ручная проверка не требуется по порогу'] + NEEDS_CHECK [note: 'Требуется ручная проверка: confidence < 100% / контрольные суммы / период или контрагент не совпали'] + CONFIRMED [note: 'Подтвержден аналитиком: данные зафиксированы, доступны консолидации'] + ERROR [note: 'документ не разобран (напр. не найдена шапка ОСВ)'] +} + +Enum s3_bucket_role { + uploads + results + exports + archive +} + +Enum recognition_status { + queued [note: 'создано, запрос в outbox'] + published [note: 'запрос опубликован в NATS'] + processing [note: 'сервис подтвердил взятие в работу (опц. heartbeat)'] + done + needs_check [note: 'результат получен, но confidence < 100% / контрольные суммы не сошлись'] + error + timeout [note: 'reply_deadline истёк без результата'] + dlq [note: 'сообщение ушло в DLQ после max_deliver'] +} + +Enum statement_type { + balance + income +} + +// v5.19: статус строки формы ББ/ОФР (05-domain-rules.md §11.4) +Enum statement_line_status { + ok [note: 'код разобран'] + unreadable [note: 'в ячейке кода есть цифры, но код не разобран (сырой текст — line_code_text)'] + missing [note: 'строка без кода с содержимым, выше в документе нет строки с кодом'] + detail [note: 'расшифровка без кода под строкой с кодом (Р-8): в §7, агрегаты, КФО и ЕХ не входит'] +} + +// v5.19: вариант формы — по структуре кодов (§11.7) +Enum statement_form_variant { + full + simplified [note: 'упрощённая форма (приказ 66н, прил. 5): ББ без 1100/1200, ОФР без 2100/2200/2300'] +} + +// v5.19: вид записи document_issue +Enum document_issue_kind { + warning [note: 'предупреждение (05-domain-rules.md §11.9, таблица уровней)'] + relation [note: 'жёсткое соотношение §7, не сошедшееся вне допуска и округления'] + soft [note: 'мягкое соотношение или жёсткое в пределах округления (OQ-138)'] +} + +Enum issue_severity { + error [note: 'документ needs_check'] + info [note: 'на статус не влияет'] +} + +Enum issue_source { + service [note: 'пришло в результате распознавания'] + core [note: 'посчитано ядром (проверки по словарю имён и перечню кодов; пересчёт §7 после правки)'] +} + +Enum osv_group_type { + ACCOUNT + DEPARTMENT + COUNTERPARTY + CONTRACT + OTHER +} + +Enum counterparty_match_kind { + INN [note: 'автоматический резолв при маппинге: ИНН строки совпал с legal_entity.inn'] + NAME [note: 'автоматический резолв: точное совпадение имени строки с legal_entity.name'] + ALIAS [note: 'автоматический резолв: нормализованное имя ЮЛ или алиас (legal_entity_alias)'] + MANUAL [note: 'ручное сопоставление аналитиком'] + PROPAGATED [note: 'автоприменение ручного сопоставления к строкам с тем же именем в версии (DOC-221278 п. 12–13)'] +} + +Enum osv_data_type { + BU + NU [note: 'v5.15: налоговый учёт из ОСВ «БУ, НУ» (OQ-45)'] + CURRENCY + QUANTITY + SUM +} + +Enum consolidation_scenario { + CREATE [note: '«Создание КФО» при отсутствии клиентской консолидации'] + CONFIRM [note: '«Подтверждение КФО клиента»'] +} + +Enum consolidation_approach { + BASIC [note: 'базовый: ББ, ОФР, ОСВ 01,58,60,62,66,67,76,90'] + EXTENDED [note: 'опция к базовому: + ОСВ 02,03,08,44,26,91,51,52,59,80,83,86,97,001,008,009,011'] +} + +// Статусы КФО по ТЗ 6.10 + «Корректировка» при расхождении ≥5% (ТЗ 6.4) +Enum consolidation_status { + INITIATED + DOCUMENTS_PROCESSING + DATA_CORRECTION [note: 'корректировка распознанных данных'] + CONSOLIDATION_RF + ADJUSTMENT [note: '«Корректировка» ВГО (расхождение ≥ 5%); подстатус этапа консолидации'] + VERIFICATION_RF + CONSOLIDATION_GO + VERIFICATION_GO [note: 'РКА ГО, если КФО инициировано КА ГО (ТЗ 6.10 маршрут)'] + VERIFIED + CANCELLED + // v5.1 по макету — ANL-035: подтвердить с заказчиком + REJECTED [note: 'Отклонено: возвращено на доработку (макет). Альтернатива — событие в status_history без отдельного статуса'] + ARCHIVED [note: 'Архив: скрыто из основного списка (макет); применимо к VERIFIED/CANCELLED'] +} + +Enum correction_status { + DRAFT + APPLIED + REVOKED +} + +Enum document_check_result { + MATCH + MISMATCH + UNKNOWN +} + +Enum le_history_event { + CREATE + UPDATE + MOVE_TO_GROUP [note: '«Переход в другую ГК»'] + CLOSE [note: '«Закрытие» / вывод из состава ГК'] + REMOVE_FROM_GROUP +} + +Enum ex_snapshot_status { + ACTUAL + ARCHIVE +} + +// v5.13: объекты и действия матрицы прав (ТЗ Прил. 2 «Роль: перечень полномочий», сноска 29) +Enum permission_object { + DIRECTORY [note: 'справочники ГК/ГСК/ГСЗ/ЮЛ'] + DOCUMENT [note: 'первичные документы и распознанные данные'] + CONSOLIDATION [note: 'консолидация, версии, корректировки, гудвилл'] + KFO [note: 'результат КФО: утверждение, верификация'] + EX [note: 'Единое хранилище'] + EXPORT + USERS [note: 'пользователи и роли'] + AUDIT [note: 'журнал действий'] +} + +Enum permission_action { + VIEW + EDIT + APPROVE [note: 'утверждение в РФ'] + VERIFY [note: 'верификация в ГО'] + DELETE + EXPORT + ADMIN +} + +Enum audit_section { + DIRECTORIES + CONSOLIDATION + PRIMARY_DOCUMENTS + RECOGNITION + KFO + EXPORT + ACCESS + SESSION + USERS +} + +Enum audit_action { + LOGIN + LOGIN_FAILED + LOGOUT + CONSOLIDATION_CREATE + CONSOLIDATION_STATUS_CHANGE + CONSOLIDATION_APPROVE + CONSOLIDATION_VERIFY + GROUP_CHANGE + LEGAL_ENTITY_CHANGE [note: 'v5.14: ЮЛ создано/изменено/закрыто/переведено, алиасы'] + MEMBERSHIP_CHANGE + ASSIGNMENT_CHANGE + FILE_UPLOAD + FILE_DELETE + FILE_REPLACE + DOCUMENT_CONFIRM + CONSOLIDATION_LOCK + CONSOLIDATION_LOCK_TAKEOVER + DISCLOSURE_EDIT + RECOGNIZED_DATA_EDIT + OSV_COUNTERPARTY_MAP + OSV_TYPE_OVERRIDE + CORRECTION_CREATE + CORRECTION_EDIT + CORRECTION_REVOKE + GOODWILL_EDIT + EXPORT + DIRECTORY_IMPORT + USER_ROLE_CHANGE + USER_CHANGE [note: 'v5.14: пользователь создан/изменён/деактивирован администратором'] + ROLE_PERMISSION_CHANGE +} + +// ============ СПРАВОЧНИКИ ============ + +// ИСТОЧНИК: +// ТЗ: ТЗ 8.1 (пользователь ГО / пользователь РФ; закрепление ГК за РФ); Прил. 2 «Пользователь: привязка к РФ/ГО» +// Аналитика: md «Модель данных» (regional_office) +// Макет: справочник ГК: колонка «Основной РФ»; пользователи: «Ростовский РФ» и т.п. +// В модели: v4 +// Зачем: справочник филиалов; DECISION-3 — ГО явной записью +Table regional_office { + id uuid [pk] + name varchar [not null, unique] + is_head_office boolean [not null, default: false, note: 'DECISION-3: ГО — явная запись'] + is_active boolean [not null, default: true] + Note: 'Филиал: ГО и региональные филиалы (РФ)' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.1 (справочник ГК, импорт из ЕЦП CRM, создание вручную Куратором); Прил. 2 «ГК: id, наименование, id ЕЦП CRM» +// Аналитика: md «Модель данных» (company_group, SCD4) +// Макет: «Группы компаний»: Создать ГК, CRM ID «генерируется автоматически», Архив +// В модели: v4 + v5.1 (code, crm_id nullable — ANL-037) +Table company_group { + id uuid [pk] + code varchar [not null, unique, note: 'v5.1 ANL-037: внутренний код ГК, генерируется системой'] + crm_id varchar [unique, note: 'ключ из ЕЦП CRM; NULL для ГК, созданной вручную Куратором до сверки с CRM'] + name varchar [not null] + base_regional_office_id uuid [ref: > regional_office.id, note: 'закрепление ГК за филиалом (ТЗ 6.1/8.1); перезакрепление Куратором = UPDATE + снимок в history'] + is_archived boolean [not null, default: false, note: 'v5.1 макет: «Архив» ГК'] + valid_from timestamptz [not null] + updated_by uuid [ref: > app_user.id] + created_at timestamptz + updated_at timestamptz + Note: 'ГК — ТЕКУЩЕЕ состояние (SCD4). История — company_group_history' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.10 «фиксация изменений ГК/ГСК в истории изменений»; 8.2 «изменение состава ГСК, ГК» +// Аналитика: md «Модель данных» (company_group_history) +// Макет: «История изменений» в карточке ГК +// В модели: v4 +// Зачем: прошлые снимки ГК (SCD4) +Table company_group_history { + id uuid [pk] + company_group_id uuid [not null, ref: > company_group.id] + crm_id varchar + name varchar + base_regional_office_id uuid + valid_from timestamptz + valid_to timestamptz + changed_by uuid [ref: > app_user.id] + Indexes { (company_group_id, valid_from) [unique] } +} + + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.1 (ЮЛ/ИП, только одна ГСК/ГСЗ); Прил. 2 «ЮЛ: id, наименование, ИНН, id CRM, ГК, признак»; ЕХ ТЗ 6.1 «Монозаёмщик» +// Аналитика: md «Модель данных» (legal_entity, SCD4) +// Макет: «Юридические лица», «ЮЛ вне ГК» / «Монозаемщики» +// В модели: v4 + v5 (crm_id, is_mono_borrower, ГК nullable); v5.17 — kpp +Table legal_entity { + id uuid [pk] + inn varchar [not null, unique] + crm_id varchar [unique, note: 'ADD: id ЮЛ в ЕЦП CRM (Прил. 2 ТЗ)'] + entity_type entity_type + name varchar [not null] + company_group_id uuid [ref: > company_group.id, note: 'nullable: NULL = монозаёмщик (ЕХ 6.1), признак выводится'] + regional_office_id uuid [ref: > regional_office.id] + close_date date [note: 'NULL = активна'] + valid_from timestamptz [not null] + updated_by uuid [ref: > app_user.id] + created_at timestamptz + updated_at timestamptz + kpp varchar [note: 'v5.17 (добавлена последней колонкой, как её добавляет миграция 0005): КПП, nullable; 9 символов (4 цифры, 2 цифры/латинские A–Z, 3 цифры); у ИП запрещён — проверяет ядро (INVALID_KPP). Источник: макет [50][51-02][61][79], решение владельца 07.10.2026 (OQ-31)'] + Note: 'Организация (ЮЛ/ИП) — ТЕКУЩЕЕ состояние (SCD4). История — legal_entity_history' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 8.2 (журнал изменений справочников); 6.10 (история изменений состава) +// Аналитика: md «Модель данных» (legal_entity_history) +// Макет: Архив ГК: события «Переход в другую ГК», «Закрытие», дата перевода +// В модели: v4 + v5.1 (event_type, effective_date — ANL-046); v5.17 — kpp +Table legal_entity_history { + id uuid [pk] + legal_entity_id uuid [not null, ref: > legal_entity.id] + inn varchar + crm_id varchar + entity_type entity_type + name varchar + company_group_id uuid + regional_office_id uuid + close_date date + event_type le_history_event [note: 'v5.1 макет: «Переход в другую ГК» / «Закрытие» — для архива ГК'] + effective_date date [note: 'дата перевода/закрытия (не раньше текущей — ANL-046)'] + valid_from timestamptz + valid_to timestamptz + changed_by uuid [ref: > app_user.id] + kpp varchar [note: 'v5.17 (последней колонкой, миграция 0005): снимок КПП на момент события'] + Indexes { (legal_entity_id, valid_from) [unique] } +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.5 «ведение справочника соответствий наименований контрагентов», «использование ранее подтверждённых сопоставлений» +// Аналитика: md «Сводная ОСВ» (алгоритм ручного сопоставления, legal_entity_alias); FNR01 п.9 +// Макет: карточка ЮЛ: «Альтернативные наименования», «Связь с компанией из ОСВ (алиас)» +// В модели: v4 +Table legal_entity_alias { + id uuid [pk] + legal_entity_id uuid [not null, ref: > legal_entity.id] + alias varchar [not null, note: 'как в ОСВ'] + normalized_alias varchar [not null, note: 'lower/trim/без пунктуации/без ОПФ'] + created_by uuid [ref: > app_user.id] + created_at timestamptz + Indexes { + (legal_entity_id, normalized_alias) [unique] + normalized_alias [note: 'ANL-028: конфликт «один алиас у разных ЮЛ» решается при ручном сопоставлении'] + } + Note: 'Словарь соответствий наименований контрагентов (ТЗ 6.5)' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.1 (ГСК/ГСЗ в составе ГК; иерархия не поддерживается; в одной ГК несколько ГСК — сноска 11); Прил. 2 «ГСК/ГСЗ» +// Аналитика: md «Модель данных» (related_group, kind) +// Макет: вкладки «ГСК», «ГСЗ», коды GSK-001/GSZ-001 +// В модели: v4; v5.19 — crm_id +Table related_group { + id uuid [pk] + code varchar [not null, unique] + company_group_id uuid [not null, ref: > company_group.id] + kind related_group_kind [not null] + name varchar [not null] + created_date date + crm_id varchar [unique, note: 'v5.19: ID ГСК/ГСЗ в ЕЦП CRM (формы 51-03, 51-04, 77-02; arm-core#112, OQ-33); вводится вручную, импорт ЕЦП не заполняет (OQ-91); NULL — не задан'] + Note: 'ГСК/ГСЗ — current-state. Иерархия групп не поддерживается (ТЗ 6.1)' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.1 «КА может редактировать состав ГСК»; «одно ЮЛ только в одной ГСК или ГСЗ» +// Аналитика: md «Модель данных» (related_group_member, SCD2) +// Макет: «Состав», «Входит в ГСК / ГСЗ», «Редактировать состав» +// В модели: v4 (индекс уточнён — ANL-030); v5.14 — kind и индекс (legal_entity_id, kind) +Table related_group_member { + id uuid [pk] + related_group_id uuid [not null, ref: > related_group.id] + legal_entity_id uuid [not null, ref: > legal_entity.id] + kind related_group_kind [not null, note: 'v5.14: денормализация related_group.kind ради частичного уникального индекса; равенство с группой проверяет ядро при записи'] + valid_from timestamptz [not null] + valid_to timestamptz + is_current boolean [not null] + created_by uuid [ref: > app_user.id] + Indexes { + (related_group_id, legal_entity_id) [note: 'partial UNIQUE WHERE is_current'] + (legal_entity_id, kind) [note: 'partial UNIQUE WHERE is_current — v5.14: ЮЛ в одной ГСК И одной ГСЗ одновременно (решение владельца 04.10.2026, отменяет ANL-030); ГК у ЮЛ и у группы должна совпадать (проверяет ядро)'] + } + Note: 'Состав ГСК/ГСЗ — SCD2. Исключение = закрытие версии (valid_to, is_current=false)' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.2/6.5 (счета и субсчета 01…90, нормализация 60.1=60.01); Прил. 3/4 (пары счетов); сноска 32 +// Аналитика: md «Модель данных» (chart_of_account); md «Шахматка» (kind A/P/AP, row_type) +// Макет: фильтр «Дебетовые/Кредитовые счета», полный план счетов 1С в бандле +// В модели: v4 + v5 (row_type, is_off_balance) +Table chart_of_account { + id uuid [pk] + code varchar [not null, unique, note: 'нормализованный код: 60.01, 76.07.1'] + parent_id uuid [ref: > chart_of_account.id] + name varchar [not null] + kind account_kind + row_type account_row_type [not null, note: 'FIX: используется в «Шахматке»'] + is_off_balance boolean [not null, default: false, note: '001, 008, 009, 011'] + Note: 'План счетов' +} + + +// ADD ТЗ Прил. 3/4 как данные (сноски 32/35: пары могут корректироваться на этапе реализации) +// ИСТОЧНИК: +// ТЗ: ТЗ 6.4 + Прил. 3 «Перечень статей ББ» и Прил. 4 «Модель расчётов ОФР»; сноски 32/35 «могут быть скорректированы на этапе реализации» +// Аналитика: md «Автоматическое сопоставление ВГО» (таблица «Правила встречных счетов» — хардкод) +// Макет: — +// В модели: v5 новая +// Зачем: правила элиминации как данные, версионируемые +Table elimination_rule { + id uuid [pk] + statement_type statement_type [not null, note: 'balance = Прил. 3, income = Прил. 4'] + rule_no smallint [not null, note: '№ строки приложения'] + article_1_line varchar [not null, note: 'статья Компании 1, код строки ББ/ОФР, напр. 1230'] + article_2_line varchar [not null, note: 'статья Компании 2, напр. 1520'] + side_1 varchar [not null, note: 'D | K — сторона Компании 1'] + measure varchar [not null, note: 'closing | turnover — сальдо на конец (ББ) или оборот (ОФР)'] + elimination_mode varchar [not null, note: 'min | full | manual — min(A,B) / вся сумма ВГО (ОФР выручка) / вручную'] + vat_handling varchar [note: 'none | check_90_01_vs_2110 | weighted_rate — только для ОФР'] + excise_adjust boolean [not null, default: false] + comment text + version_no int [not null] + valid_from date [not null] + valid_to date + Indexes { (statement_type, rule_no, version_no) [unique] } + Note: 'Правило элиминации. Состав счетов — в elimination_rule_account' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ Прил. 3/4 (столбцы «Пары счетов Компании 1 и Компании 2», исключения «76 кроме 76.7.1») +// Аналитика: md «Автоматическое сопоставление ВГО» +// Макет: — +// В модели: v5 новая +// Зачем: состав счетов сторон правила +Table elimination_rule_account { + id uuid [pk] + rule_id uuid [not null, ref: > elimination_rule.id] + company_no smallint [not null, note: '1 или 2'] + account_id uuid [not null, ref: > chart_of_account.id] + is_excluded boolean [not null, default: false, note: 'true = исключение внутри маски (напр. «76 кроме 76.7.1»)'] + Note: 'Счета/субсчета сторон правила' +} + + +// ============ ПОЛЬЗОВАТЕЛИ И ДОСТУП ============ + +// ИСТОЧНИК: +// ТЗ: ТЗ 8.1 (роли, ГО/РФ, ЗО); Прил. 2 «Пользователь: ФИО, роль, привязка к РФ/ГО, ЗО, табельный номер»; РАИСА +// Аналитика: md «Аутентификация и авторизация» (users: email, role, JWT РАИСА); md «Модель данных» (app_user) +// Макет: «Пользователи»: Логин, Роль, Активен, Деактивация +// В модели: v4 + v5 (employee_no, business_segment) +Table app_user { + id uuid [pk] + external_id varchar [unique, note: 'субъект РАИСА/SSO; аутентификация внешняя (ANL-018)'] + email varchar [unique, note: 'v5.15: хранится в нижнем регистре (нормализация при записи), сравнение по lower(email) (OQ-59)'] + full_name varchar [not null] + employee_no varchar [note: 'ADD: табельный номер (Прил. 2 ТЗ)'] + regional_office_id uuid [not null, ref: > regional_office.id, note: 'ГО или РФ (DECISION-3)'] + business_segment business_segment [note: 'ADD: зона ответственности ДКБ/ДМСБ/ДМ (ТЗ 8.1)'] + is_active boolean [not null, default: true] + created_at timestamptz + updated_at timestamptz +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 8.1 таблица ролей (КА РФ, КА ГО, РКА, КМ, Куратор, Эксперт, Администратор); Прил. 2 «Роль» +// Аналитика: md «Ролевая модель»; md «Модель данных» (role.code) +// Макет: «Изменение ролей и прав доступа» (Аналитик ДКБ, Наблюдатель… — расходится с ТЗ, ANL-034) +// В модели: v4 (коды → ТЗ в v5); v5.13 — матрица полномочий в role_permission +Table role { + id uuid [pk] + code varchar [not null, unique, note: 'ТЗ 8.1: KA_RF | KA_GO | RKA | CLIENT_MANAGER | CURATOR_GO | EXPERT | ADMIN. Seed; администратор может заводить дополнительные роли (замена исключениям по пользователю)'] + name varchar [not null] + description text + is_system boolean [not null, default: false, note: 'роли ТЗ 8.1 — не удаляются'] + is_active boolean [not null, default: true] + Note: 'Роль. Полномочия — через role_permission (v5.13); код роли в бизнес-логике больше не проверяется' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ Прил. 2 «Роль: перечень полномочий»; сноска 29 «матрица доступов будет разработана» +// Аналитика: md «Ролевая модель» +// Макет: карточка роли: «Права: Просмотр, редактирование КФО / справочники / консолидации» +// В модели: v5 новая, убрана в v5.3/v5.4, ВОЗВРАЩЕНА в v5.13 (замечание 1) +// Зачем: атомарное право «объект × действие»; seed, администратор новые коды не создаёт +Table permission { + id uuid [pk] + code varchar [not null, unique, note: 'напр. CONSOLIDATION_EDIT, KFO_VERIFY, DIRECTORY_EDIT, USERS_ADMIN'] + object_type permission_object [not null] + action permission_action [not null] + name varchar [not null] + description text + Indexes { (object_type, action) [unique] } + Note: 'Справочник прав (seed, привязан к коду). Ограничения по ЗО/филиалу и статусу КФО — отдельный policy-слой поверх прав: право говорит «может», policy — «эту ли и сейчас ли»' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ Прил. 2 «Роль: перечень полномочий» +// Аналитика: нет +// Макет: «Права» в карточке роли, «Изменение ролей и прав доступа» +// В модели: v5.13 (возвращена) +// Зачем: матрица доступов как данные; редактируется администратором, история — audit_event(ROLE_PERMISSION_CHANGE) +Table role_permission { + role_id uuid [not null, ref: > role.id] + permission_id uuid [not null, ref: > permission.id] + granted_by uuid [ref: > app_user.id] + granted_at timestamptz + Indexes { (role_id, permission_id) [pk] } + Note: 'effective_permissions(user) = ∪ permissions(roles(user)). Прямых прав у пользователя нет (решение 30.09): исключения — через заведение роли' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 8.1 (роль пользователя); Прил. 2 «Пользователь — Роль: многие-к-одному» +// Аналитика: md «Модель данных» v4 (user_role M:N) +// Макет: «Роль» пользователя +// В модели: v4 +// Зачем: M:N оставлен (пользователь может быть КА и Куратором); если ТЗ строго 1:1 — заменить на app_user.role_id +Table user_role { + id uuid [pk] + user_id uuid [not null, ref: > app_user.id] + role_id uuid [not null, ref: > role.id] + Indexes { (user_id, role_id) [unique] } + Note: 'M:N current-state. История назначений — audit_event(USER_ROLE_CHANGE)' +} + +// ============ ФАЙЛЫ / РАСПОЗНАВАНИЕ ============ + +// ИСТОЧНИК: +// ТЗ: 6.2 (файлы с префиксом UUID, исходные документы хранятся); 8.4 «хранение 5 лет с даты прекращения отношений, архивирование/удаление»; ЕХ 6.2 «ссылка на исходные документы с возможностью скачать», ЕХ 8.3 «архивирование в data lake» +// Аналитика: md «Модель данных» v4 (s3_key, raw_result_s3_key — строки) +// Макет: — +// В модели: v5.2 новая (DECISION-4), подтверждена в v5.4 +// Зачем: единая точка ключей всех бакетов для lifecycle/ретеншна и дедупа; без реестра ключи размазаны по 4 таблицам +Table s3_object { + id uuid [pk] + bucket_role s3_bucket_role [not null] + bucket varchar [not null] + key varchar [not null, note: 'схема ключа: {file_id}/{job_id}/{name}'] + content_type varchar + size_bytes bigint + sha256 varchar + version_id varchar [note: 'S3 object version при включённом versioning'] + created_by varchar [note: 'arm-core | tabular-parser | scan-gateway | user:{id}'] + created_at timestamptz [not null] + expires_at timestamptz [note: 'lifecycle: NULL = весь срок (ТЗ 8.4); exports/ = 7 дней; results/ — решение ARCH-010'] + archived_to varchar [note: 'ссылка в data lake после архивирования (ЕХ 8.3)'] + Indexes { (bucket, key) [unique] } + Note: 'Реестр объектов S3. stored_file / recognition_job ссылаются сюда FK' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.2 (типы и форматы документов, UUID); 8.2 «первичные документы не редактируются, только замена с версионностью» +// Аналитика: md «Загрузка документов» (панель, UC-01…05); FNR01 «Маппинг stored_file» +// Макет: «Первичные документы», «Загрузка документов», «Замена документа», «Удаление документа» +// В модели: v4 + v5 (version_no, superseded_by) + v5.2 (s3_object_id); v5.13 — только ФИЗИЧЕСКИЙ файл, документы → document; v5.18 — consolidation_id +Table stored_file { + id uuid [pk] + legal_entity_id uuid [not null, ref: > legal_entity.id, note: 'организация-владелец (выбрана при загрузке)'] + file_type file_type [not null] + original_name varchar + s3_object_id uuid [not null, ref: > s3_object.id, note: 'исходный файл в uploads'] + content_hash varchar [note: 'sha256, дедуп (UC-01 A2)'] + size_bytes bigint + status file_status [not null] + version_no int [not null, default: 1, note: 'ADD ТЗ 8.2: версия при замене'] + superseded_by_file_id uuid [ref: > stored_file.id, note: 'ссылка на файл-замену; статус SUPERSEDED. Замена — файл ЦЕЛИКОМ со всеми документами в нём (решение 01.10)'] + current_recognition_job_id uuid [ref: > recognition_job.id, note: 'авторитетный прогон; его результат породил актуальные document'] + uploaded_by uuid [ref: > app_user.id] + uploaded_at timestamptz + updated_at timestamptz + consolidation_id uuid [ref: > consolidation.id, note: 'v5.18: консолидация (период ГК), в контексте которой загружен файл — основа «рабочего стола консоли» (полный состав — context/05-domain-rules.md §9.5 п. 11), общего для версий периода; NULL — без контекста. Пишется при загрузке; при автозамене файл без контекста наследует значение заменённого'] + Indexes { + (legal_entity_id, status) + (legal_entity_id, consolidation_id) [note: 'v5.18: основа рабочего стола консоли для ЮЛ (§9.5 п. 11)'] + current_recognition_job_id [note: 'v5.15: проверка FK при удалении recognition_job / файла (OQ-117)'] + } + Note: 'v5.13: физический файл одной загрузки (S3 — только ссылка). Что внутри — document (1:N). Первичка не редактируется. Удаление физическое запрещено после INITIATED — только SUPERSEDED (ANL-020). v5.18: consolidation_id — где загружен; что использует версия — consolidation_document' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.2 (типы документов: ББ, ОФР, ОСВ по счетам; обязательный набор); 5.1 (подход); 8.2; Прил. 2 «Документ ФО» +// Аналитика: md «Загрузка документов» (чек-лист, UC-01 A1 период, «Подтвердить данные»); md «Парсинг ОСВ» (счёт/период из шапки) +// Макет: «Чек-лист загруженных документов», «Соответствует период», «Не соответствует контрагент», «Подтвердить данные» +// В модели: v5.13 новая (замечание 6-бис); v5.19 — file_ordinal, уникальность типа только для не-форм +// Зачем: один файл = N документов (PDF ББ+ОФР; xlsx с несколькими ОСВ). Чек-лист, проверки, подтверждение и +// FK разобранных данных — здесь; без этого «ОСВ 58 загружена?» по файлу не ответить +Table document { + id uuid [pk] + stored_file_id uuid [not null, ref: > stored_file.id] + legal_entity_id uuid [not null, ref: > legal_entity.id, note: 'денормализация stored_file.legal_entity_id — выборки чек-листа по ЮЛ+период'] + document_type document_type [not null] + osv_account_code varchar [note: 'для OSV — счёт по шапке (01, 60, 62…), нормализованный; NULL для прочих типов'] + period_year smallint [note: 'из шапки/формы'] + period_quarter period_quarter + is_cumulative boolean [note: 'true = нарастающий итог с начала года'] + reporting_date date [note: 'отчётная дата, если определена'] + locator varchar [note: 'где в файле: лист xlsx / диапазон страниц PDF (напр. «sheet:ОСВ 58», «pages:1-2»)'] + status document_status [not null] + period_check document_check_result [note: 'UC-01 A1 / макет «Соответствует период»'] + counterparty_check document_check_result [note: 'макет «Не соответствует контрагент» — ИНН/наименование из шапки vs legal_entity'] + confirmed_by uuid [ref: > app_user.id, note: 'кто нажал «Подтвердить данные» → audit_event(DOCUMENT_CONFIRM)'] + confirmed_at timestamptz + created_at timestamptz + updated_at timestamptz + file_ordinal smallint [not null, default: 0, note: 'v5.19: индекс документа в documents[] результата распознавания (с 0), у всех документов при любом MAJOR контракта; несозданный документ номер не занимает (номера с пропусками); различает две формы одного типа в файле (Р-10)'] + Indexes { + (stored_file_id, document_type, osv_account_code) [note: 'partial UNIQUE WHERE document_type NOT IN (\'BALANCE\', \'INCOME\') — NULLS NOT DISTINCT; v5.19: одна ОСВ по счёту / одна расшифровка / один прочий на файл; формы ББ/ОФР одного типа в файле могут повторяться (Р-10)'] + (stored_file_id, file_ordinal) [unique, note: 'v5.19: номер документа уникален в файле'] + (legal_entity_id, document_type, osv_account_code, period_year, period_quarter, status) [note: 'чек-лист required_document_rule'] + } + Note: 'Логический документ внутри файла. Создаётся ядром при маппинге результата recognition_job. Разобранные данные: OSV → osv_entry; BALANCE/INCOME → financial_statement_meta. Счётчики «исправлено полей» — COUNT(is_modified) по данным, не хранятся. v5.19: предупреждения и нарушения §7 — document_issue; две формы одного типа и периода в файле не автоподтверждаются (Р-18)' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.2 (статусы процесса распознавания, ИС Сканирование); 6.10 «повторная обработка»; 7.1 +// Аналитика: FNR01 «Маппинг recognition_job»; md «Загрузка документов» UC-02; md «Парсинг ОСВ» (схема группировок шапки) +// Макет: «Система распознавания: получен ответ», «Обработка», «Ошибка» +// В модели: v4, переписана в v5.2 под контракт NATS (DECISION-4); v5.13 — group_schema jsonb, убран raw_result +Table recognition_job { + id uuid [pk, note: 'job_id в контракте NATS'] + file_id uuid [not null, ref: > stored_file.id] + pipeline recognition_pipeline [not null, note: 'tabular → tabular-parser; image → scan-gateway'] + request_subject varchar [not null, note: 'recognition.tabular.request | recognition.image.request'] + request_msg_id varchar [unique, note: 'Nats-Msg-Id запроса (outbox)'] + result_msg_id varchar [unique, note: 'Nats-Msg-Id результата (inbox, дедуп)'] + schema_version varchar [not null, note: 'версия контракта сообщений'] + service_name varchar [note: 'tabular-parser | scan-gateway — из результата'] + service_version varchar + status recognition_status [not null] + attempt smallint [not null, default: 1] + max_attempts smallint [not null, default: 1, note: 'OCR: 1 (0 повторов), парсер: 3'] + requested_at timestamptz + published_at timestamptz + started_at timestamptz + finished_at timestamptz + reply_deadline timestamptz [note: 'таймаут ожидания (OCR — 20 мин)'] + external_task_id varchar [note: 'id задания в ИС Сканирование (только image)'] + error_code varchar [note: 'UNSUPPORTED_FORMAT | UNREADABLE | TIMEOUT | SCHEMA_MISMATCH | …'] + error_message text + result_s3_object_id uuid [ref: > s3_object.id, note: 'нормализованный JSON в results — единственная ссылка на результат (v5.13). Сырой ответ ИС Сканирование — S3 raw/{job_id}.json по конвенции, в БД не регистрируется'] + result_schema_version varchar [note: 'версия схемы JSON результата (ARCH-008/009)'] + group_schema jsonb [note: 'v5.13 (вместо osv_group_schema): схема группировок шапки ОСВ по листам — [{\"locator\":\"sheet:ОСВ 58\",\"groups\":[{\"type\":\"ACCOUNT\"},{\"type\":\"COUNTERPARTY\",\"has_inn\":true},{\"type\":\"CONTRACT\"}]}]; только для tabular'] + metrics jsonb [note: 'duration_ms, pages, rows, warnings (v5.19 / контракт 2.0: предупреждения уровня файла — структуры {code, severity, message, locator?}; error уровня файла блокирует RECOGNIZED документов файла)'] + fields_total int [note: 'v5.7: всего извлечённых значений (ячейки формы / строки ОСВ)'] + fields_low_confidence int [note: 'v5.7: значений с confidence < 100 (ТЗ 6.2) — заполняется при маппинге результата'] + fields_unrecognized int [note: 'v5.7: значений с is_recognized = false'] + statements_version varchar [note: 'v5.19: service.statements_version результата — коммит копии общего кода arm_statements; при пересчёте после правки сравнивается с копией ядра (расхождение — document_issue info)'] + Indexes { (file_id, attempt) [unique] } + Note: 'РЕШЕНИЕ v5.4: единственная точка обмена с NATS. Запись = outbox: создаётся в queued в одной транзакции с stored_file, фоновый публикатор переводит в published (при сбое публикации — повтор по queued старше N сек); результат приходит по NATS, дедуп по result_msg_id UNIQUE; redelivery после max_deliver → status=dlq, replay = сброс в queued с attempt+1. Ядро маппит JSON из S3 в document → osv_entry / financial_statement_*' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.2 (Osv_report_lines); 6.5 (строка ОСВ: компания, счёт, контрагент, признак, 6 сумм); Прил. 2 «Строка сводной ОСВ», «Распознанный показатель» +// Аналитика: FNR01 «Маппинг osv_entry»; md «Парсинг ОСВ» п.11 (row_number, level, group_type, data_type) +// Макет: «Оборотно-сальдовая ведомость»: Дебет/Кредит начало/оборот/конец, «Тип контрагента» +// В модели: v4 + v5 (owner_legal_entity_id — FIX, parent_entry_id, is_modified, is_excisable); v5.13 — document_id, group_type вместо group_schema_id; v5.16 — counterparty_match_kind +Table osv_entry { + id uuid [pk] + recognition_job_id uuid [not null, ref: > recognition_job.id] + document_id uuid [not null, ref: > document.id, note: 'v5.13: ОСВ-документ (счёт + период); файл — через document.stored_file_id'] + owner_legal_entity_id uuid [not null, ref: > legal_entity.id, note: 'FIX: организация-ВЛАДЕЛЕЦ ОСВ (денормализация). В v4 называлось legal_entity_id с ошибочной note'] + parent_entry_id uuid [ref: > osv_entry.id, note: 'родитель в дереве строк ОСВ (ТЗ 6.5). ОТКРЫТО (замечание 2, отложено 01.10): хранить ли только контрагентские строки и нужно ли дерево'] + group_type osv_group_type [note: 'v5.13: тип строки по схеме шапки (recognition_job.group_schema) — ACCOUNT / DEPARTMENT / COUNTERPARTY / CONTRACT / OTHER'] + group_name varchar [note: 'исходное название группировки из шапки («Контрагенты», «Договоры»)'] + row_number int + level smallint + row_value varchar [note: 'исходный текст строки'] + data_type osv_data_type + account_code varchar [note: 'сырой код счёта'] + account_id uuid [ref: > chart_of_account.id] + account_root varchar + counterparty_name varchar [note: 'сырое имя из ОСВ, не изменяется'] + counterparty_inn varchar [note: 'сырой ИНН, не изменяется'] + counterparty_id uuid [ref: > legal_entity.id, note: 'резолв по ИНН/имени/алиасу или ручное сопоставление; NULL = не сопоставлен. Признак ГСК/ГК/Внешний выводится из периметра версии (DECISION-5)'] + opening_debit numeric(18,2) + opening_credit numeric(18,2) + turnover_debit numeric(18,2) + turnover_credit numeric(18,2) + closing_debit numeric(18,2) + closing_credit numeric(18,2) + is_modified boolean [not null, default: false, note: 'значения правились аналитиком'] + original_values jsonb [note: 'v5.6: суммы до первой ручной правки {opening_debit,…}; NULL = не правилось'] + modified_by uuid [ref: > app_user.id] + modified_at timestamptz + is_excisable boolean [note: 'ADD ТЗ 6.4: признак «подакцизный товар» для ОСВ 90'] + counterparty_match_kind counterparty_match_kind [note: 'v5.16: NULL ⇔ counterparty_id NULL; INN/NAME/ALIAS — шаг автоматического резолва при маппинге; MANUAL — ручное сопоставление; PROPAGATED — автоприменение по имени в версии (DOC-221278 п. 12–13)'] + Indexes { + (document_id, row_number) [unique] + (owner_legal_entity_id, account_id, counterparty_id) + parent_entry_id [note: 'v5.15: обход дерева строк и проверка self-FK при удалении документа (OQ-117)'] + recognition_job_id [note: 'v5.15: проверка FK при удалении recognition_job / файла (OQ-117)'] + } + Note: 'Строка ОСВ. Правки сумм аналитиком — original_values/modified_by + audit_event(RECOGNIZED_DATA_EDIT, before/after)' +} + + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.2 «извлекаются значения по кодам форм Приказа 157н»; 6.3 проверки единиц измерения/даты +// Аналитика: FNR01 «fin_report_header» (report_type, org, unit, currency, reporting_date); md «Модель данных» v4 +// Макет: «Реквизиты организации» (ОКПО, ОКОПФ, ОКФС, ОКУД, ОКЕИ, адрес), «Единица измерения», «Отчетная дата» +// В модели: v4 (FIX pk) + v5.1 (реквизиты — ANL-044); v5.13 — document_id; v5.19 — form_variant, needs_review_fields +Table financial_statement_meta { + id uuid [pk, note: 'FIX: был без PK'] + document_id uuid [not null, unique, ref: > document.id, note: 'v5.13: документ BALANCE/INCOME (1:1); файл — через document'] + recognition_job_id uuid [not null, ref: > recognition_job.id] + statement_type statement_type [not null] + inn varchar + inn_original_value varchar + inn_confidence numeric(5,2) + inn_verified boolean + inn_modified boolean + organization_name varchar [note: 'как в документе; сверяется с legal_entity/alias, при несовпадении — document.counterparty_check'] + report_date date + report_date_original_value varchar [note: 'FIX: строка как распознано (был `date []`)'] + report_date_verified boolean + report_date_modified boolean + units varchar [note: 'тыс. руб. / руб.'] + units_original_value varchar + units_verified boolean + units_modified boolean + currency varchar [default: 'RUB'] + okpo varchar [note: 'v5.1 ANL-044: реквизиты по макету экрана распознавания'] + okopf varchar + okfs varchar + okud varchar [note: '0710001 / 0710002'] + okei varchar + legal_form varchar [note: 'ОПФ'] + ownership_form varchar + address varchar + subject_to_audit boolean + reviewed_by uuid [ref: > app_user.id, note: 'v5.9: кто проверил данные формы; подтверждение документа — document.confirmed_by'] + reviewed_at timestamptz + checks_accepted_by uuid [ref: > app_user.id, note: 'v5.9 ТЗ 6.3 п.7: акцепт КА непройденных проверок (напр. 1370/2400) с комментарием'] + checks_accepted_at timestamptz + checks_accepted_comment text + form_variant statement_form_variant [note: 'v5.19: full | simplified — по структуре кодов (§11.7); выбирает набор соотношений §7; NULL — документ до v5.19 или результат 1.x: набор полный'] + needs_review_fields jsonb [note: 'v5.19: имена полей шапки, требующих проверки (["report_date"]), как пришло от сервиса; правка поля или подтверждение тем же значением убирает его из списка'] + header_source jsonb [note: 'v5.19: шапка результата как пришла — {"<поле>": {"text": <поле>_text, "confidence": <поле>_confidence}} для organization_name, inn, report_date, units, okud, okpo, okopf, okfs, okei, address; не меняется. *_original_value — как прежде: inn_original_value = inn, report_date_original_value = report_date_text, units_original_value = units_text; inn_confidence = header_source.inn.confidence'] + organization_name_original_value varchar [note: 'v5.19: наименование организации из результата; правками не меняется'] + organization_name_modified boolean [note: 'v5.19: наименование правилось аналитиком (PATCH шапки)'] + Note: 'Шапка распознанной формы ББ/ОФР. v5.19: результат проверок §7 и предупреждения хранятся в document_issue как пришли от сервиса и пересчитываются ядром только после правок (пересматривает «не хранятся — считаются на лету»)' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.3 п.7 «стр. 1370 на конец и на начало года»; 6.7 «УК ДО на отчётную дату» +// Аналитика: FNR01 «rows[0].cells[2..4] — наименования колонок периода»; md «Логические проверки» («по каждой отчётной дате (колонке)») +// Макет: колонки «На 30 сентября 2025 г.», «На 31 декабря 2024 г.», «Выбор периодов» +// В модели: v5 новая (FIX-14) +// Зачем: дата колонки — без неё межформенная проверка и гудвилл не считаются +Table financial_statement_column { + id uuid [pk] + meta_id uuid [not null, ref: > financial_statement_meta.id] + column_index smallint [not null] + label varchar [note: 'как в форме: «На 30 сентября 2025 г.», «За 9 месяцев 2025 г.»'] + period_date date [note: 'ADD FIX-14: дата колонки — нужна для 1370(нач.года), гудвилла «на отчётную дату», сравнения версий'] + is_current boolean [note: 'колонка отчётного периода'] + period_kind varchar [note: 'v5.19: absolute | relative:current | relative:prev_year_end | relative:prev_prev_year_end | relative:prev_period | none; относительные подписи бланка 66н — period_date от эффективной даты документа, ядро досчитывает после правки даты отчёта (Р-6)'] + label_confidence numeric(5,2) [note: 'v5.19: уверенность подписи колонки (Excel — 100; подписи нет — NULL)'] + Indexes { (meta_id, column_index) [unique] } + Note: 'Колонки-периоды формы (2–3 сравнительные даты)' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.2 (коды статей по формам 157н); 6.3 (Таблица 1 — коды строк 1100…2400) +// Аналитика: FNR01 «fin_report_rows» (row_name, row_code, section, is_total_row); md «Модель данных» v4 +// Макет: «Результат распознавания»: Наименование показателя, Код, разделы +// В модели: v4 (FIX: убраны копипаст-поля); v5.19 — строки без кода и повторы кода, статусы, порядок, needs_review +Table financial_statement_line { + id uuid [pk] + meta_id uuid [not null, ref: > financial_statement_meta.id] + line_code varchar [note: 'код строки ОКУД, напр. 1230'] + line_code_original_value varchar [note: 'v5.19: line_code строки из результата как пришёл (NULL у unreadable / missing / detail и у строк, созданных правкой); правками кода не меняется. До v5.19 NULL был признаком «создана правкой» — теперь это row_index IS NULL'] + line_code_confidence numeric(5,2) + line_code_modified boolean + line_name varchar + line_name_original_value varchar + line_name_confidence numeric(5,2) + line_name_modified boolean [note: 'FIX: был numeric'] + row_index int [note: 'v5.19: порядок строки в документе источника (с 0). NULL ⇔ строка создана правкой аналитика (шаблонная, origin = template); у строк из результата — всегда не NULL (маппер 1.x и 2.0)'] + source_ref varchar [note: 'v5.19: положение в источнике (row:8, row:14#2, Баланс!C27); NULL у строк, созданных правкой'] + line_status statement_line_status [not null, default: 'ok', note: 'v5.19: ok | unreadable | missing | detail; line_code пуст ⇔ статус не ok'] + line_code_text varchar [note: 'v5.19: сырой текст ячейки кода, как распознано'] + parent_row_index int [note: 'v5.19: row_index строки-родителя у detail; пересчитывается при правке'] + needs_review boolean [not null, default: false, note: 'v5.19: неуверенный код не подтверждён структурой (как пришло от сервиса; правка или подтверждение кода — false)'] + is_confirmed boolean [not null, default: false, note: 'v5.19: код подтверждён аналитиком без изменения (PATCH lines confirmed)'] + Indexes { + (meta_id, line_code) [note: 'v5.19: не уникален — повтор кода законен (Р-4); чтения «по коду» — агрегат формы по коду'] + (meta_id, source_ref) [note: 'partial UNIQUE WHERE source_ref IS NOT NULL — v5.19: положение в источнике уникально в форме'] + } + Note: 'Строка формы как в источнике (v5.19): строки с кодом, без кода (unreadable / missing / detail) и повторы кода; раскладка экрана по коду и шаблон формы — на фронте и в ядре по словарю имён' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.2 «уверенность распознавания ниже 100% → Требуется ручная проверка»; Прил. 2 «Распознанный показатель: значение, уверенность, признак ручной корректировки» +// Аналитика: FNR01 (value_current/prior/prior2, confidence, скобки → отрицательное); md «Логические проверки» (правка ячеек) +// Макет: подсветка ячеек, «не распознано», «Редактировать/Сохранить», «Подтвердить данные» +// В модели: v4 + v5 (column_id, confidence, original_amount, modified_by); v5.13 — убран needs_review; v5.19 — needs_review хранится, source_text, is_confirmed +Table financial_statement_value { + id uuid [pk] + line_id uuid [not null, ref: > financial_statement_line.id] + column_id uuid [not null, ref: > financial_statement_column.id] + amount numeric(18,2) + string_value varchar [note: 'как распознано, если не число'] + confidence numeric(5,2) [note: 'уверенность распознавания (Прил. 2 ТЗ). v5.19: подсветка — только needs_review (уверенность, is_recognized и §7 сведены сервисом)'] + is_recognized boolean + is_modified boolean [not null, default: false] + original_amount numeric(18,2) [note: 'значение до ручной правки'] + modified_by uuid [ref: > app_user.id] + modified_at timestamptz + source_text varchar [note: 'v5.19: сырой текст ячейки источника (values[].text), не меняется'] + needs_review boolean [not null, default: false, note: 'v5.19: как пришло от сервиса (подтверждение проверкой §7, 05-domain-rules.md §11.9), пересчитывается ядром после правок; is_modified или is_confirmed ⇒ false. Пересматривает п. 4 v5.13'] + is_confirmed boolean [not null, default: false, note: 'v5.19: подтверждено аналитиком без изменения значения (is_modified не ставится)'] + confirmed_by uuid [ref: > app_user.id] + confirmed_at timestamptz + Indexes { (line_id, column_id) [unique] } +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.3 (логические проверки, акцепт); 6.2 «уверенность ниже 100% → Требуется ручная проверка» +// Аналитика: openspec/changes/unified-statement-normalization (design: шаг 8, «Что уходит из ядра») +// Макет: «Результат распознавания»: ошибки проверок, подсветка ячеек +// В модели: v5.19 новая +// Зачем: предупреждения сервиса и нарушения §7 с привязкой к строкам, ячейкам и полям шапки — экран показывает их у +// места и после сохранения; ядро не пересчитывает их при загрузке (Decisions № 17, 19) +Table document_issue { + id uuid [pk] + document_id uuid [not null, ref: > document.id] + kind document_issue_kind [not null] + code varchar [not null, note: 'код предупреждения (05-domain-rules.md §11.9); у relation — RELATION_FAILED (severity error), у soft — RELATION_SOFT (severity info)'] + severity issue_severity [not null] + message text [not null] + line_ids jsonb [note: 'массив financial_statement_line.id, к которым относится запись (нарушение — все строки кодов соотношения); FK нет — при удалении шаблонной строки (назначение её кода строке источника) ядро убирает её id из line_ids всех записей документа'] + column_id uuid [ref: > financial_statement_column.id] + column_index smallint [note: 'столбец сетки из предупреждения, если колонки нет среди financial_statement_column (COLUMN_SKIPPED и др.); у колонки формы — column_id'] + codes jsonb [note: 'для relation / soft: коды цели и членов соотношения (["1600","1100","1200"]); экран подсвечивает строки этих кодов'] + field varchar [note: 'поле шапки (report_date, inn, organization_name, …)'] + source issue_source [not null] + relation varchar [note: 'для relation / soft: текст соотношения (1600 = 1100 + 1200)'] + expected numeric(18,2) [note: 'для relation / soft: цель'] + actual numeric(18,2) [note: 'для relation / soft: правая часть'] + created_at timestamptz [not null] + Indexes { document_id } + Note: 'v5.19: предупреждения и нарушения документа (формы — предупреждения и нарушения §7; ОСВ — предупреждения листа OSV_* результата 2.0). Удаляются вместе с документом при перезагрузке файла; при пересчёте после правки relation / soft заменяются целиком, warning источника service сохраняются, кроме снимаемых правкой (таблица §11.9)' +} + + +// ============ КОНСОЛИДАЦИЯ ============ + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.10 (процесс формирования КФО за отчётный период); 7.2 «формируется по требованию»; Прил. 2 «Консолидация: ГСК, отчётный период» +// Аналитика: md «Сводная ОСВ» / «Шахматка» (consolidation.year/quarter); md «Модель данных» v4 +// Макет: «Создание консолидации: ГК → ГСК → Квартал» +// В модели: v4 + v5 (reporting_date) +// Зачем: логическая консолидация ГСК+период; версии — ниже +Table consolidation { + id uuid [pk] + company_group_id uuid [not null, ref: > company_group.id, note: 'v5.9: консолидация создаётся от ГК'] + related_group_id uuid [ref: > related_group.id, note: 'v5.9: ГСК — NULL до утверждения; фиксируется/создаётся при VERIFIED по составу consolidation_member (ANL-029)'] + year smallint [not null] + quarter period_quarter [not null] + reporting_date date [not null, note: 'ADD: 01.04 / 01.07 / 01.10 / 01.01 след. года — для ЕХ и сравнений'] + created_at timestamptz + Indexes { (company_group_id, year, quarter) [unique] } + Note: 'Логическая консолидация ГК за период; версии различаются периметром (consolidation_member). Прошлые периоды — по company_group_id. v5.18: файлы, загруженные в консолидацию, — stored_file.consolidation_id (основа рабочего стола; полный состав — context/05-domain-rules.md §9.5 п. 11)' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 5.1 (сценарий, подход, чекбокс Гудвилл); 6.10 (статусная модель, маршрут по инициатору, № и дата решения УОБ, «новая версия КФО» после Верифицирован); 6.4 (причины отклонений от Методики) +// Аналитика: md «Статусная модель»; md «Модель данных» v4 (consolidation_version: version_no, status varchar, initiator, approved_in_rf, verified_in_go, kfo_confirmation_enabled) +// Макет: «Версия», «Дата верификации», «Включить/Отключить подтверждение КФО», «Смена статуса отчёта», «Ответственный / Утвердил / Верифицировал» +// В модели: v4 + v5 (enum статусов, scenario, approach, goodwill_enabled, initiator_office_id, uob_*, methodology_deviations, previous_version_id) +Table consolidation_version { + id uuid [pk] + consolidation_id uuid [not null, ref: > consolidation.id] + version_no int [not null] + status consolidation_status [not null, note: 'CHG: enum вместо varchar'] + scenario consolidation_scenario [not null, note: 'ADD ТЗ 5.1'] + approach consolidation_approach [not null, default: 'BASIC', note: 'ADD ТЗ 5.1'] + goodwill_enabled boolean [not null, default: false, note: 'ADD ТЗ 5.1/6.7: чек-бокс расчёта Гудвилла'] + initiator_id uuid [not null, ref: > app_user.id] + initiator_office_id uuid [not null, ref: > regional_office.id, note: 'ГО/РФ инициатора — определяет маршрут (ТЗ 6.10)'] + approved_in_rf_by uuid [ref: > app_user.id] + approved_in_rf_at timestamptz + verified_in_go_by uuid [ref: > app_user.id] + verified_in_go_at timestamptz + uob_decision_no varchar [note: 'ADD ТЗ 6.10: № решения УОБ'] + uob_decision_date date [note: 'ADD ТЗ 6.10'] + methodology_deviations text [note: 'ADD ТЗ 6.4: причины отклонений от Методики'] + elimination_rules_version int [note: 'v5.11: версия elimination_rule (version_no), по которой считалась версия; фиксируется при первом расчёте, замораживается при VERIFIED'] + is_in_work boolean [not null, default: true] + previous_version_id uuid [ref: > consolidation_version.id, note: '«новая версия КФО» после VERIFIED'] + cancelled_reason text + created_at timestamptz + created_by uuid [ref: > app_user.id] + updated_by uuid [ref: > app_user.id] + updated_at timestamptz + Indexes { (consolidation_id, version_no) [unique] } + Note: 'Версия консолидации. После VERIFIED — read-only; изменения только через новую версию' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.10 (переходы, возврат на доработку); 8.2 «изменение статусов; согласование и утверждение КФО» +// Аналитика: md «Статусная модель» (переходы) +// Макет: «Смена статуса отчёта», «Комментарий (обязательно)», «Отклонено: отчёт возвращён на доработку» +// В модели: v5 новая +Table consolidation_status_history { + id uuid [pk] + version_id uuid [not null, ref: > consolidation_version.id] + from_status consolidation_status + to_status consolidation_status [not null] + changed_by uuid [not null, ref: > app_user.id] + changed_at timestamptz [not null] + comment text [note: 'обязателен при возврате на доработку'] + Note: 'ADD ТЗ 6.10/8.2: маршрут согласования' +} + +// ADD v5.1 (макет, ANL-036): «взять в работу» / перехват прав редактирования +// ИСТОЧНИК: +// ТЗ: нет в ТЗ (ANL-036) +// Аналитика: нет +// Макет: «Взятие КФО в работу (перехват прав редактирования)», «Причина: Отпуск», «В данный момент консолидация редактируется пользователем …» +// В модели: v5.1 новая (макет) +// Зачем: блокировка версии; подтвердить с заказчиком +// v5.16: без истечения по времени (OQ-119): активна, пока не снята владельцем/администратором или не перехвачена по причине +Table consolidation_lock { + id uuid [pk] + consolidation_version_id uuid [not null, ref: > consolidation_version.id] + locked_by uuid [not null, ref: > app_user.id] + locked_at timestamptz [not null] + released_at timestamptz [note: 'NULL = активная блокировка'] + taken_over_from uuid [ref: > app_user.id, note: 'при перехвате — предыдущий владелец'] + takeover_reason text [note: 'обязателен при перехвате (макет: «Причина: Отпуск»)'] + Indexes { consolidation_version_id [note: 'partial UNIQUE WHERE released_at IS NULL'] } + Note: 'Ответственный за версию сейчас = активная блокировка' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.1 «КА в рамках консолидации редактирует состав ГСК»; 6.10 «выбор состава ГСК»; ЕХ 6.3 «Периметр на дату» +// Аналитика: md «Сводная ОСВ» п.2 («зафиксированный периметр; изменение related_group_member не меняет версию»); md «Модель данных» v4 +// Макет: «Состав ЮЛ», «Минимум для консолидации: 2», «Редактировать состав» +// В модели: v4 + v5 (is_head_entity для Гудвилла) +Table consolidation_member { + id uuid [pk] + consolidation_version_id uuid [not null, ref: > consolidation_version.id] + legal_entity_id uuid [not null, ref: > legal_entity.id] + is_head_entity boolean [not null, default: false, note: 'ADD: головная организация (для Гудвилла)'] + Indexes { (consolidation_version_id, legal_entity_id) [unique] } + Note: 'Зафиксированный периметр версии. Изменение related_group_member не меняет существующие версии' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ Прил. 2 «Документ ФО: ссылка на Консолидацию и ЮЛ»; 6.4/6.5 «использование данных предыдущих периодов»; ЕХ 7 «повторное использование первички без дублей» +// Аналитика: md «Сводная ОСВ» п.3 (файлы выбираются по ЮЛ+год+квартал — без явной связи) +// Макет: «Первичные документы консолидации» +// В модели: v5 новая; v5.13 — document_id вместо stored_file_id +// Зачем: явная привязка документа к версии (переиспользование, монозаёмщики из ЕХ) +// v5.16: точки привязки/отвязки — подтверждение документа, добавление ЮЛ в периметр; замена/удаление файла, исключение ЮЛ (Срез 4, D-5) +Table consolidation_document { + id uuid [pk] + consolidation_version_id uuid [not null, ref: > consolidation_version.id] + document_id uuid [not null, ref: > document.id, note: 'v5.13: логический документ (ОСВ по счёту / ББ / ОФР), не файл'] + Indexes { (consolidation_version_id, document_id) [unique] } + Note: 'ADD Прил. 2 ТЗ «Документ ФО → Консолидация»: явная привязка документов к версии (переиспользование в нескольких версиях/периодах, монозаёмщики из ЕХ). Чек-лист = required_document_rule × периметр − consolidation_document' +} + + + + +// ---- Корректировки ---- +// ИСТОЧНИК: +// ТЗ: ТЗ 6.8 (форма корректировок: колонки ДБ/КР/Комментарий, >5%, импорт/экспорт, отдельная форма ОФР); 6.4 (доп. корректировки ОФР); Прил. 2 «Расхождение/корректировка»; Прил. 9 +// Аналитика: md «Корректировка данных» (correction: parent_correction_id, debit/credit_line_code, adjustment); md «Модель данных» v4 +// Макет: «Таблица корректировки»: Актив/Пассив/Контроль/Корректировка, «Строка баланса Дт/Кт», «Комментарий» +// В модели: v4 + v5 (statement_type, pair_match_id, reason_code, comment NOT NULL, status, version_no) +Table correction { + id uuid [pk] + consolidation_version_id uuid [not null, ref: > consolidation_version.id] + statement_type statement_type [not null, note: 'ADD: ББ или ОФР (ТЗ 6.8 — отдельные формы)'] + rule_id uuid [ref: > elimination_rule.id, note: 'v5.10: пара ВГО по естественному ключу (правило + компании); NULL = ручная без пары'] + parent_correction_id uuid [ref: > correction.id, note: 'дочерняя строка распределения по нескольким статьям; глубина 1'] + legal_entity_id uuid [ref: > legal_entity.id, note: 'компания 1 (чья первичка корректируется)'] + counterparty_id uuid [ref: > legal_entity.id, note: 'компания 2'] + debit_line_code varchar + debit_account_id uuid [ref: > chart_of_account.id] + debit_adjustment numeric(18,2) + credit_line_code varchar + credit_account_id uuid [ref: > chart_of_account.id] + credit_adjustment numeric(18,2) + deviation_amount numeric(18,2) + deviation_percent numeric(9,4) + reason_code varchar [note: 'ADD ТЗ 6.4: unrealized_revenue | excise | agency_commission | intra_lease_depreciation | intra_loan_interest | equity_income | other'] + comment text [not null, note: 'ТЗ 6.8: комментарий обязателен'] + status correction_status [not null, default: 'DRAFT'] + version_no int [not null, default: 1, note: 'сноска 22 ТЗ: история версий корректировки — audit_event(CORRECTION_EDIT, before/after); инкремент при каждом изменении'] + created_by uuid [ref: > app_user.id] + created_at timestamptz + updated_by uuid [ref: > app_user.id] + updated_at timestamptz + Indexes { + consolidation_version_id + (consolidation_version_id, rule_id, legal_entity_id, counterparty_id) + } + Note: 'Overlay поверх первички: итог = свод первички − Σ применённых корректировок. Расхождение пары (deviation_*) — справочно, пересчитывается' +} + + +// ---- Гудвилл (ТЗ 6.7, Прил. 3 п.2, Прил. 8) ---- +// ИСТОЧНИК: +// ТЗ: ТЗ 6.7 (Гудвилл: ФВ ГО − доля в капитале ДО, комментарий обязателен, отдельная строка ББ); Прил. 3 п.2 (58.01 против 80/83, стр. 1310/1350/1370); Прил. 8 +// Аналитика: md «Гудвилл» (шаги 1–10: 58.01, стр. 1310, доля владения, include_in_kfo); md «Модель данных» v4 (goodwill) +// Макет: «Таблица гудвилла», «Доля владения», «Учет в отчете», «Изменение долей владения (гудвилл)» +// В модели: v4 + v5 (additional_capital_amount, comment) +Table goodwill { + id uuid [pk] + consolidation_version_id uuid [not null, ref: > consolidation_version.id] + head_legal_entity_id uuid [not null, ref: > legal_entity.id] + subsidiary_legal_entity_id uuid [not null, ref: > legal_entity.id] + ownership_share numeric(7,4) [note: 'ручной ввод, %'] + financial_investment_amount numeric(18,2) [note: 'ΣДБ 58.01 ГО по ДО'] + charter_capital_amount numeric(18,2) [note: 'стр. 1310 ББ ДО на отчётную дату'] + additional_capital_amount numeric(18,2) [note: 'ADD ТЗ Прил. 3: стр. 1350 ББ ДО'] + participation_amount numeric(18,2) [note: '(1310 + 1350) * share / 100 — DECISION: уточнить у методологов, входит ли 1350 в базу'] + goodwill_amount numeric(18,2) + include_in_kfo boolean [not null, default: false] + comment text [note: 'ТЗ 6.7: обязателен при include_in_kfo'] + created_by uuid [ref: > app_user.id] + created_at timestamptz + updated_by uuid [ref: > app_user.id] + updated_at timestamptz + Indexes { (consolidation_version_id, head_legal_entity_id, subsidiary_legal_entity_id) [unique] } +} + +// ---- Результат КФО (ТЗ 6.4, Прил. 10) ---- +// ИСТОЧНИК: +// ТЗ: ТЗ 6.4 (итоговая форма КФО: подход, Гудвилл, расхождения, корректировки, верификация); 6.8 (контрольные пороги 10% ЧА / 1% ВБ, 10% прибыли / 1% выручки); Прил. 10 +// Аналитика: нет (пробел — ANL-008/009) +// Макет: «КФО», «Сводка расхождений КФО», «Есть несохранённые изменения в КФО» +// В модели: v5 новая +// Зачем: шапка результата + пороги +Table kfo_result { + id uuid [pk] + consolidation_version_id uuid [not null, unique, ref: > consolidation_version.id] + computed_at timestamptz [not null] + is_frozen boolean [not null, default: false, note: 'true после VERIFIED'] + net_assets numeric(18,2) [note: 'ЧА ГСК'] + balance_total numeric(18,2) [note: 'ВБ ГСК'] + revenue numeric(18,2) + sales_profit numeric(18,2) + total_deviation_bb numeric(18,2) [note: 'Σ расхождений по статьям ББ'] + total_deviation_ofr numeric(18,2) + threshold_bb_exceeded boolean [note: 'ТЗ 6.8: > 10% ЧА (или > 1% ВБ, если ЧА < 10% ВБ)'] + threshold_ofr_exceeded boolean [note: '> 10% прибыли от продаж (или > 1% выручки при рентабельности < 10%)'] + threshold_justification text [note: 'обоснование принятия риска при превышении'] + goodwill_total numeric(18,2) + has_deviations boolean + Note: 'Шапка результата КФО + контрольные пороги' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.4 «шаг 1 — сложение по кодам строк; шаг 2 — элиминация; неэлиминируемая часть остаётся»; Прил. 2 «Результат КФО: статья, значение»; Прил. 10; ФСБУ 4/2023 +// Аналитика: нет (пробел — ANL-008) +// Макет: колонки «в т.ч. взаимные обязательства», «Очищенные показатели», «Расхождения», «источник: Баланс/ОСВ», «в т.ч. Гудвилл» +// В модели: v5 новая + v5.1 (vgo_amount, source_kind, deviation_amount, comment) +Table kfo_result_line { + id uuid [pk] + kfo_result_id uuid [not null, ref: > kfo_result.id] + statement_type statement_type [not null] + line_code varchar [not null, note: 'код строки ФСБУ 4/2023; GOODWILL — отдельная строка раздела I'] + line_name varchar + sum_amount numeric(18,2) [note: 'шаг 1: простое сложение по компаниям'] + eliminated_auto numeric(18,2) [note: 'шаг 2: авто-элиминация по правилам'] + eliminated_manual numeric(18,2) [note: 'Σ применённых корректировок'] + goodwill_adjust numeric(18,2) + consolidated_amount numeric(18,2) [not null, note: 'итог («Очищенные показатели» в макете)'] + vgo_amount numeric(18,2) [note: 'v5.1 макет: «в т.ч. взаимные обязательства/обороты» = eliminated_auto + eliminated_manual'] + source_kind varchar [note: 'v5.1 макет: balance | osv — источник значения статьи'] + deviation_amount numeric(18,2) [note: 'v5.1 макет «Расхождения» по статье'] + comment text [note: 'v5.1 макет: пояснение к строке КФО'] + ordinal int + Indexes { (kfo_result_id, statement_type, line_code) [unique] } +} + +// ИСТОЧНИК: +// ТЗ: ЕХ ТЗ 6.6 «выгрузка расшифровки статей отчётности в разрезе контрагентов»; ТЗ 6.9 (настраиваемые поля экспорта) +// Аналитика: нет +// Макет: «Доля в показателе по группе, %», расшифровка по компаниям +// В модели: v5 новая + v5.1 (share_percent) +Table kfo_result_line_company { + kfo_result_line_id uuid [not null, ref: > kfo_result_line.id] + legal_entity_id uuid [not null, ref: > legal_entity.id] + amount numeric(18,2) + share_percent numeric(7,4) [note: 'v5.1 макет: «Доля в показателе по группе, %»'] + Indexes { (kfo_result_line_id, legal_entity_id) [pk] } + Note: 'Разрез по компаниям для расшифровки статьи (ЕХ 6.6 «расшифровка в разрезе контрагентов»)' +} + +// ---- Сценарий «Подтверждение КФО клиента» (ТЗ 5.1, 6.10) ---- +// ИСТОЧНИК: +// ТЗ: ТЗ 5.1 «подтверждение консолидированной отчётности, предоставленной Клиентом»; 6.10 «ручной ввод данных КФО клиента; сравнение и анализ расхождений»; Методика сноска 14 (процент к наименьшему) +// Аналитика: md «Статусная модель» (kfo_confirmation_enabled — только флаг; ANL-006) +// Макет: «Подтверждение КФО клиента»: Версия клиента / Версия банка, ВГО клиента / ВГО Банка, Расхождение, Причина +// В модели: v5 новая + v5.1 (client_vgo/bank_vgo/deviation_reason) +Table client_kfo_line { + id uuid [pk] + consolidation_version_id uuid [not null, ref: > consolidation_version.id] + statement_type statement_type [not null] + line_code varchar [not null] + client_amount numeric(18,2) [note: 'ручной ввод из КФО клиента («Консолидированная отчетность от клиента»)'] + client_vgo_amount numeric(18,2) [note: 'v5.1 макет: «ВГО клиента» — ручной ввод'] + bank_amount numeric(18,2) [note: 'из kfo_result_line на момент сравнения'] + bank_vgo_amount numeric(18,2) [note: 'v5.1 макет: «ВГО Банка» из kfo_result_line.vgo_amount'] + deviation_amount numeric(18,2) + deviation_percent numeric(9,4) [note: 'относительно наименьшего (Методика сноска 14)'] + deviation_reason text [note: 'v5.1 макет: «Причина расхождения»'] + comment text + entered_by uuid [ref: > app_user.id] + entered_at timestamptz + Indexes { (consolidation_version_id, statement_type, line_code) [unique] } +} + + + +// ============ КОММЕНТАРИИ, АУДИТ, ЭКСПОРТ ============ + +// ИСТОЧНИК: +// ТЗ: ТЗ 6.3 «возможность добавления комментария к отчётности»; 6.4 «комментарии» в форме КФО; 6.5/6.8 «обязательный комментарий» +// Аналитика: md «Загрузка документов» («Комментарий к документу») +// Макет: «Новый комментарий», «Комментарий к документу», комментарии в ячейках шахматки, «Редактировать комментарии» в КФО +// В модели: v5 новая +// Зачем: полиморфные комментарии к объектам +Table comment { + id uuid [pk] + object_type varchar [not null, note: 'document | consolidation_version | chess_cell(ключ пары) | kfo_result_line | …'] + object_id uuid [not null] + text text [not null] + author_id uuid [not null, ref: > app_user.id] + created_at timestamptz [not null] + Indexes { (object_type, object_id) } + Note: 'ADD ТЗ 6.3/6.4: комментарии к отчётности и КФО (в т.ч. обязательные при расхождениях)' +} + +// ИСТОЧНИК: +// ТЗ: ТЗ 8.2 (журнал изменений, логирование всех действий, перечень событий: вход/выход, создание консолидации, ГСК/ГК, загрузка, распознанные данные, корректировки, статусы, утверждение, выгрузка; хранение ≥ срока данных); Прил. 2 «Запись журнала изменений» +// Аналитика: нет (пробел — ANL-019); md «Ролевая модель» ссылается на «журнал аудита» +// Макет: «Журнал действий»: разделы, Объект/Событие/Что изменилось/Результат/Идентификатор, IP/браузер, «неверный пароль» +// В модели: v5 новая + v5.1 (section, result, changed_field, user_agent) +Table audit_event { + id uuid [pk] + occurred_at timestamptz [not null] + user_id uuid [ref: > app_user.id, note: 'NULL для системных событий'] + action audit_action [not null] + section audit_section [not null, note: 'v5.1 макет: раздел журнала'] + object_type varchar [not null] + object_id uuid + consolidation_version_id uuid [ref: > consolidation_version.id, note: 'для выборки «история изменений КФО»'] + description text + before_value jsonb + after_value jsonb + comment text + result varchar [note: 'v5.1 макет: Успешно / Отклонено / Ошибка'] + changed_field varchar [note: 'v5.1 макет: фильтр «Изменяемое поле»'] + ip_address varchar + user_agent varchar [note: 'v5.1 макет: браузер в событиях сеанса'] + Indexes { + (consolidation_version_id, occurred_at) + (object_type, object_id) + (user_id, occurred_at) + } + Note: 'ТЗ 8.2: журнал изменений/аудит. Append-only. Хранение ≥ срока хранения консолидации. Также покрывает: историю правок распознанных данных, факт/состав выгрузок (ЕХ 6.6), импорт справочников, изменения матрицы прав' +} + + + +// ============ ЕХФД (ТЗ BIQ-34649) ============ +// DECISION-2: срез = замороженный указатель на верифицированную версию + список её артефактов. + +// ИСТОЧНИК: +// ТЗ: ЕХ ТЗ 6.2 «запись срезами: одна ГСК на одну отчётную дату, после Верифицирован»; 6.4 «версии среза Актуальная/Архив, неизменяемость»; 5.3 «архивирование в data lake» +// Аналитика: нет (пробел — ANL-032) +// Макет: «Единое хранилище», «Сформированная отчетность», «Верифицирована» +// В модели: v5 новая (DECISION-2) +// Зачем: замороженный указатель на верифицированную версию +Table ex_snapshot { + id uuid [pk] + related_group_id uuid [not null, ref: > related_group.id] + company_group_id uuid [not null, ref: > company_group.id, note: 'денормализация на момент верификации'] + reporting_date date [not null] + year smallint [not null] + quarter period_quarter [not null] + version_no int [not null] + status ex_snapshot_status [not null, note: 'ACTUAL — последняя верифицированная; предыдущие → ARCHIVE'] + source_version_id uuid [not null, unique, note: 'v5.12: id consolidation_version-источника БЕЗ FK — источник удаляется по сроку, срез нет'] + source_version_no int [not null] + superseded_by_snapshot_id uuid [ref: > ex_snapshot.id, note: 'v5.12: для ARCHIVE — ссылка на актуальный срез, который его заместил'] + verified_at timestamptz [not null] + written_at timestamptz [not null] + written_by uuid [ref: > app_user.id, note: 'инициатор записи'] + archived_at timestamptz + data_lake_ref varchar [note: 'ссылка на архив в data lake'] + Indexes { + (related_group_id, year, quarter, version_no) [unique] + (related_group_id, year, quarter, status) + } + Note: 'Срез данных ЕХ: одна ГСК на одну отчётную дату (ЕХ 6.2). Неизменяем и самодостаточен (не зависит от таблиц консолидации). Состав — ex_snapshot_item + ex_snapshot_member' +} + +// ИСТОЧНИК: +// ТЗ: ЕХ ТЗ 6.3 «Периметр на дату: состав ЮЛ/ИП группы на отчётную дату» +// Аналитика: нет +// Макет: дерево «ГК → ГСК → Организации» с ИНН +// В модели: v5 новая +// Зачем: денормализация периметра на дату среза +Table ex_snapshot_member { + ex_snapshot_id uuid [not null, ref: > ex_snapshot.id] + legal_entity_id uuid [not null, ref: > legal_entity.id] + inn varchar [not null, note: 'денормализация на дату среза'] + name varchar [not null] + Indexes { (ex_snapshot_id, legal_entity_id) [pk] } + Note: 'Периметр на дату (ЕХ 6.3)' +} + +// ИСТОЧНИК: +// ТЗ: ЕХ ТЗ 5.2 «Состав хранимых данных» (КФО, первичные, сводная ОСВ, шахматка, гудвилл, корректировки, иные отчёты); 6.7.3/6.7.4 «Сформированная отчётность / Первичные документы», «Просмотр / Выгрузка» +// Аналитика: нет +// Макет: область документов ЕХ, «Скачать», «Связать» документ с организацией +// В модели: v5 новая; v5.13 — document_id вместо stored_file_id +// Зачем: состав среза со ссылками на объекты +Table ex_snapshot_item { + id uuid [pk] + ex_snapshot_id uuid [not null, ref: > ex_snapshot.id] + item_type varchar [not null, note: 'primary_document | summary_osv | vgo_pairs | chess | kfo_balance | kfo_income | goodwill | corrections | other_report'] + legal_entity_id uuid [ref: > legal_entity.id, note: 'для первички — организация'] + document_id uuid [ref: > document.id, note: 'v5.13: только для primary_document — исходники хранятся весь срок, FK допустим; «Скачать» — через document.stored_file → s3_object'] + payload jsonb [note: 'v5.12 DECISION-5: замороженные данные для всех остальных типов; самодостаточно, без ссылок на таблицы консолидации'] + payload_schema_version varchar + Indexes { (ex_snapshot_id, item_type) } + Note: 'Состав среза: primary_document → document; всё остальное → payload. «Просмотр» в ЛК ЕХ рендерит payload по схеме; «Выгрузка» собирает xlsx из payload на лету' +} + +// ============ ПРОЧЕЕ ============ + +// ИСТОЧНИК: +// ТЗ: ТЗ 5.1 (базовый / расширенный пакет ОСВ); 6.2 «набор обязательных ОСВ»; сноска 12 «процесс останавливается, если отсутствует документ» +// Аналитика: md «Загрузка документов» (чек-лист: «ОСВ по обязательным счетам», «прочие документы») +// Макет: «Чек-лист загруженных документов», «Условные обозначения статусов» +// В модели: v5 новая +// Зачем: конфигурация чек-листа по подходу; с v5.13 сверяется с document (тип + счёт + период), а не с файлом +Table required_document_rule { + id uuid [pk] + approach consolidation_approach [not null] + document_type document_type [not null] + account_id uuid [ref: > chart_of_account.id, note: 'для OSV'] + is_mandatory boolean [not null, note: 'обязателен → блокирует переход к консолидации (сноска 12 ТЗ)'] + Indexes { (approach, document_type, account_id) [unique] } + Note: 'ADD ТЗ 5.1/6.2: состав обязательных документов по подходу — основа чек-листа. Выполнено = есть document CONFIRMED с таким типом/счётом за период у каждого ЮЛ периметра' +} diff --git a/model/diagrams/part01_groups_entities.png b/model/diagrams/part01_groups_entities.png index 4687549..d4b7ae4 100644 Binary files a/model/diagrams/part01_groups_entities.png and b/model/diagrams/part01_groups_entities.png differ diff --git a/model/diagrams/part01_groups_entities.svg b/model/diagrams/part01_groups_entities.svg index c99f1c9..b2febbb 100644 --- a/model/diagrams/part01_groups_entities.svg +++ b/model/diagrams/part01_groups_entities.svg @@ -4,9 +4,9 @@ - - + + dbml @@ -26,17 +26,17 @@ related_group_kind - - - -       related_group_kind        - - -    gsk     - - -    gsz     - + + + +       related_group_kind        + + +    gsk     + + +    gsz     + @@ -462,42 +462,42 @@ legal_entity_alias - - - -       legal_entity_alias        - - -id -     -uuid - - -legal_entity_id     -uuid - -(!) - - -alias     -varchar - -(!) - - -normalized_alias     -varchar - -(!) - - -created_by     -uuid - - -created_at     -timestamptz - + + + +       legal_entity_alias        + + +id +     +uuid + + +legal_entity_id     +uuid + +(!) + + +alias     +varchar + +(!) + + +normalized_alias     +varchar + +(!) + + +created_by     +uuid + + +created_at     +timestamptz + @@ -505,165 +505,169 @@ legal_entity_alias:e->app_user:w - + 1 -* +* legal_entity_alias:e->legal_entity:w - - + + 1 -* +* related_group - - - -       related_group        - - -id -     -uuid - - -code     -varchar - -(!) - - -company_group_id     -uuid - -(!) - - -kind     -related_group_kind - -(!) - - -name     -varchar - -(!) - - -created_date     -date - + + + +       related_group        + + +id +     +uuid + + +code     +varchar + +(!) + + +company_group_id     +uuid + +(!) + + +kind     +related_group_kind + +(!) + + +name     +varchar + +(!) + + +created_date     +date + + +crm_id     +varchar + related_group:e->related_group_kind:w - + related_group:e->company_group:w - + 1 -* +* related_group_member - - - -       related_group_member        - - -id -     -uuid - - -related_group_id     -uuid - -(!) - - -legal_entity_id     -uuid - -(!) - - -kind     -related_group_kind - -(!) - - -valid_from     -timestamptz - -(!) - - -valid_to     -timestamptz - - -is_current     -boolean - -(!) - - -created_by     -uuid - + + + +       related_group_member        + + +id +     +uuid + + +related_group_id     +uuid + +(!) + + +legal_entity_id     +uuid + +(!) + + +kind     +related_group_kind + +(!) + + +valid_from     +timestamptz + +(!) + + +valid_to     +timestamptz + + +is_current     +boolean + +(!) + + +created_by     +uuid + related_group_member:e->related_group_kind:w - + related_group_member:e->app_user:w - + 1 -* +* related_group_member:e->legal_entity:w - - + + 1 -* +* related_group_member:e->related_group:w - - -1 -* + + +1 +* diff --git a/model/diagrams/part04_files_jobs.png b/model/diagrams/part04_files_jobs.png index 29b8b89..2932e08 100644 Binary files a/model/diagrams/part04_files_jobs.png and b/model/diagrams/part04_files_jobs.png differ diff --git a/model/diagrams/part04_files_jobs.svg b/model/diagrams/part04_files_jobs.svg index ee162ea..5b8c97a 100644 --- a/model/diagrams/part04_files_jobs.svg +++ b/model/diagrams/part04_files_jobs.svg @@ -4,33 +4,33 @@ - - + + dbml document_type - - - -       document_type        - - -    BALANCE     - - -    INCOME     - - -    OSV     - - -    DISCLOSURE     - - -    OTHER     - + + + +       document_type        + + +    BALANCE     + + +    INCOME     + + +    OSV     + + +    DISCLOSURE     + + +    OTHER     + @@ -62,38 +62,38 @@ recognition_pipeline - - - -       recognition_pipeline        - - -    tabular     - - -    image     - + + + +       recognition_pipeline        + + +    tabular     + + +    image     + period_quarter - - - -       period_quarter        - - -    Q1     - - -    Q2     - - -    Q3     - - -    Q4     - + + + +       period_quarter        + + +    Q1     + + +    Q2     + + +    Q3     + + +    Q4     + @@ -122,95 +122,95 @@ document_status - - - -       document_status        - - -    RECOGNIZED     - - -    NEEDS_CHECK     - - -    CONFIRMED     - - -    ERROR     - + + + +       document_status        + + +    RECOGNIZED     + + +    NEEDS_CHECK     + + +    CONFIRMED     + + +    ERROR     + s3_bucket_role - - - -       s3_bucket_role        - - -    uploads     - - -    results     - - -    exports     - - -    archive     - + + + +       s3_bucket_role        + + +    uploads     + + +    results     + + +    exports     + + +    archive     + recognition_status - - - -       recognition_status        - - -    queued     - - -    published     - - -    processing     - - -    done     - - -    needs_check     - - -    error     - - -    timeout     - - -    dlq     - + + + +       recognition_status        + + +    queued     + + +    published     + + +    processing     + + +    done     + + +    needs_check     + + +    error     + + +    timeout     + + +    dlq     + document_check_result - - - -       document_check_result        - - -    MATCH     - - -    MISMATCH     - - -    UNKNOWN     - + + + +       document_check_result        + + +    MATCH     + + +    MISMATCH     + + +    UNKNOWN     + @@ -250,16 +250,16 @@ legal_entity - - - -       legal_entity        - - -id -     -uuid - + + + +       legal_entity        + + +id +     +uuid + @@ -267,346 +267,350 @@ s3_object - - - -       s3_object        - - -id -     -uuid - - -bucket_role     -s3_bucket_role - -(!) - - -bucket     -varchar - -(!) - - -key     -varchar - -(!) - - -content_type     -varchar - - -size_bytes     -bigint - - -sha256     -varchar - - -version_id     -varchar - - -created_by     -varchar - - -created_at     -timestamptz - -(!) - - -expires_at     -timestamptz - - -archived_to     -varchar - + + + +       s3_object        + + +id +     +uuid + + +bucket_role     +s3_bucket_role + +(!) + + +bucket     +varchar + +(!) + + +key     +varchar + +(!) + + +content_type     +varchar + + +size_bytes     +bigint + + +sha256     +varchar + + +version_id     +varchar + + +created_by     +varchar + + +created_at     +timestamptz + +(!) + + +expires_at     +timestamptz + + +archived_to     +varchar + s3_object:e->s3_bucket_role:w - + stored_file - - - -       stored_file        - - -id -     -uuid - - -legal_entity_id     -uuid - -(!) - - -file_type     -file_type - -(!) - - -original_name     -varchar - - -s3_object_id     -uuid - -(!) - - -content_hash     -varchar - - -size_bytes     -bigint - - -status     -file_status - -(!) - - -version_no     -int - -(!) - - -superseded_by_file_id     -uuid - - -current_recognition_job_id     -uuid - - -uploaded_by     -uuid - - -uploaded_at     -timestamptz - - -updated_at     -timestamptz - - -consolidation_id     -uuid - + + + +       stored_file        + + +id +     +uuid + + +legal_entity_id     +uuid + +(!) + + +file_type     +file_type + +(!) + + +original_name     +varchar + + +s3_object_id     +uuid + +(!) + + +content_hash     +varchar + + +size_bytes     +bigint + + +status     +file_status + +(!) + + +version_no     +int + +(!) + + +superseded_by_file_id     +uuid + + +current_recognition_job_id     +uuid + + +uploaded_by     +uuid + + +uploaded_at     +timestamptz + + +updated_at     +timestamptz + + +consolidation_id     +uuid + stored_file:e->file_type:w - + stored_file:e->file_status:w - + stored_file:e->app_user:w - + 1 -* +* stored_file:e->consolidation:w - - + + 1 -* +* stored_file:e->legal_entity:w - - -1 -* + + +1 +* stored_file:e->s3_object:w - - -1 -* + + +1 +* stored_file:e->stored_file:w - - -1 -* + + +1 +* recognition_job - - - -       recognition_job        - - -id -     -uuid - - -file_id     -uuid - -(!) - - -pipeline     -recognition_pipeline - -(!) - - -request_subject     -varchar - -(!) - - -request_msg_id     -varchar - - -result_msg_id     -varchar - - -schema_version     -varchar - -(!) - - -service_name     -varchar - - -service_version     -varchar - - -status     -recognition_status - -(!) - - -attempt     -smallint - -(!) - - -max_attempts     -smallint - -(!) - - -requested_at     -timestamptz - - -published_at     -timestamptz - - -started_at     -timestamptz - - -finished_at     -timestamptz - - -reply_deadline     -timestamptz - - -external_task_id     -varchar - - -error_code     -varchar - - -error_message     -text - - -result_s3_object_id     -uuid - - -result_schema_version     -varchar - - -group_schema     -jsonb - - -metrics     -jsonb - - -fields_total     -int - - -fields_low_confidence     -int - - -fields_unrecognized     -int - + + + +       recognition_job        + + +id +     +uuid + + +file_id     +uuid + +(!) + + +pipeline     +recognition_pipeline + +(!) + + +request_subject     +varchar + +(!) + + +request_msg_id     +varchar + + +result_msg_id     +varchar + + +schema_version     +varchar + +(!) + + +service_name     +varchar + + +service_version     +varchar + + +status     +recognition_status + +(!) + + +attempt     +smallint + +(!) + + +max_attempts     +smallint + +(!) + + +requested_at     +timestamptz + + +published_at     +timestamptz + + +started_at     +timestamptz + + +finished_at     +timestamptz + + +reply_deadline     +timestamptz + + +external_task_id     +varchar + + +error_code     +varchar + + +error_message     +text + + +result_s3_object_id     +uuid + + +result_schema_version     +varchar + + +group_schema     +jsonb + + +metrics     +jsonb + + +fields_total     +int + + +fields_low_confidence     +int + + +fields_unrecognized     +int + + +statements_version     +varchar + @@ -614,179 +618,185 @@ stored_file:e->recognition_job:w - - -1 -* + + +1 +* document - - - - -       document        - - -id -     -uuid - - -stored_file_id     -uuid - -(!) - - -legal_entity_id     -uuid - -(!) - - -document_type     -document_type - -(!) - - -osv_account_code     -varchar - - -period_year     -smallint - - -period_quarter     -period_quarter - - -is_cumulative     -boolean - - -reporting_date     -date - - -locator     -varchar - - -status     -document_status - -(!) - - -period_check     -document_check_result - - -counterparty_check     -document_check_result - - -confirmed_by     -uuid - - -confirmed_at     -timestamptz - - -created_at     -timestamptz - - -updated_at     -timestamptz - + + + + +       document        + + +id +     +uuid + + +stored_file_id     +uuid + +(!) + + +legal_entity_id     +uuid + +(!) + + +document_type     +document_type + +(!) + + +osv_account_code     +varchar + + +period_year     +smallint + + +period_quarter     +period_quarter + + +is_cumulative     +boolean + + +reporting_date     +date + + +locator     +varchar + + +status     +document_status + +(!) + + +period_check     +document_check_result + + +counterparty_check     +document_check_result + + +confirmed_by     +uuid + + +confirmed_at     +timestamptz + + +created_at     +timestamptz + + +updated_at     +timestamptz + + +file_ordinal     +smallint + +(!) + document:e->document_type:w - + document:e->period_quarter:w - + document:e->document_status:w - + document:e->document_check_result:w - + document:e->document_check_result:w - + document:e->app_user:w - - + + 1 -* +* document:e->legal_entity:w - - -1 -* + + +1 +* document:e->stored_file:w - - -1 -* + + +1 +* recognition_job:e->recognition_pipeline:w - + recognition_job:e->recognition_status:w - + recognition_job:e->s3_object:w - - -1 -* + + +1 +* recognition_job:e->stored_file:w - - -1 -* + + +1 +* diff --git a/model/diagrams/part06_statements.png b/model/diagrams/part06_statements.png index fcd98be..428e899 100644 Binary files a/model/diagrams/part06_statements.png and b/model/diagrams/part06_statements.png differ diff --git a/model/diagrams/part06_statements.svg b/model/diagrams/part06_statements.svg index 3445498..5af5201 100644 --- a/model/diagrams/part06_statements.svg +++ b/model/diagrams/part06_statements.svg @@ -4,39 +4,123 @@ - - + + dbml statement_type - - - -       statement_type        - - -    balance     - - -    income     - + + + +       statement_type        + + +    balance     + + +    income     + + + + +statement_line_status + + + +       statement_line_status        + + +    ok     + + +    unreadable     + + +    missing     + + +    detail     + + + + +statement_form_variant + + + +       statement_form_variant        + + +    full     + + +    simplified     + + + + +document_issue_kind + + + +       document_issue_kind        + + +    warning     + + +    relation     + + +    soft     + + + + +issue_severity + + + +       issue_severity        + + +    error     + + +    info     + + + + +issue_source + + + +       issue_source        + + +    service     + + +    core     + app_user - - - -       app_user        - - -id -     -uuid - + + + +       app_user        + + +id +     +uuid + @@ -44,16 +128,16 @@ document - - - -       document        - - -id -     -uuid - + + + +       document        + + +id +     +uuid + @@ -61,249 +145,282 @@ recognition_job - - - -       recognition_job        - - -id -     -uuid - + + + +       recognition_job        + + +id +     +uuid + financial_statement_meta - - - - -       financial_statement_meta        - - -id -     -uuid - - -document_id     -uuid - -(!) - - -recognition_job_id     -uuid - -(!) - - -statement_type     -statement_type - -(!) - - -inn     -varchar - - -inn_original_value     -varchar - - -inn_confidence     -numeric(5,2) - - -inn_verified     -boolean - - -inn_modified     -boolean - - -organization_name     -varchar - - -report_date     -date - - -report_date_original_value     -varchar - - -report_date_verified     -boolean - - -report_date_modified     -boolean - - -units     -varchar - - -units_original_value     -varchar - - -units_verified     -boolean - - -units_modified     -boolean - - -currency     -varchar - - -okpo     -varchar - - -okopf     -varchar - - -okfs     -varchar - - -okud     -varchar - - -okei     -varchar - - -legal_form     -varchar - - -ownership_form     -varchar - - -address     -varchar - - -subject_to_audit     -boolean - - -reviewed_by     -uuid - - -reviewed_at     -timestamptz - - -checks_accepted_by     -uuid - - -checks_accepted_at     -timestamptz - - -checks_accepted_comment     -text - + + + + +       financial_statement_meta        + + +id +     +uuid + + +document_id     +uuid + +(!) + + +recognition_job_id     +uuid + +(!) + + +statement_type     +statement_type + +(!) + + +inn     +varchar + + +inn_original_value     +varchar + + +inn_confidence     +numeric(5,2) + + +inn_verified     +boolean + + +inn_modified     +boolean + + +organization_name     +varchar + + +report_date     +date + + +report_date_original_value     +varchar + + +report_date_verified     +boolean + + +report_date_modified     +boolean + + +units     +varchar + + +units_original_value     +varchar + + +units_verified     +boolean + + +units_modified     +boolean + + +currency     +varchar + + +okpo     +varchar + + +okopf     +varchar + + +okfs     +varchar + + +okud     +varchar + + +okei     +varchar + + +legal_form     +varchar + + +ownership_form     +varchar + + +address     +varchar + + +subject_to_audit     +boolean + + +reviewed_by     +uuid + + +reviewed_at     +timestamptz + + +checks_accepted_by     +uuid + + +checks_accepted_at     +timestamptz + + +checks_accepted_comment     +text + + +form_variant     +statement_form_variant + + +needs_review_fields     +jsonb + + +header_source     +jsonb + + +organization_name_original_value     +varchar + + +organization_name_modified     +boolean + - + financial_statement_meta:e->statement_type:w - + + + + +financial_statement_meta:e->statement_form_variant:w + financial_statement_meta:e->app_user:w - - -1 -* + + +1 +* financial_statement_meta:e->app_user:w - - -1 -* + + +1 +* financial_statement_meta:e->document:w - - -1 -* + + +1 +* financial_statement_meta:e->recognition_job:w - - -1 -* + + +1 +* financial_statement_column - - - -       financial_statement_column        - - -id -     -uuid - - -meta_id     -uuid - -(!) - - -column_index     -smallint - -(!) - - -label     -varchar - - -period_date     -date - - -is_current     -boolean - + + + +       financial_statement_column        + + +id +     +uuid + + +meta_id     +uuid + +(!) + + +column_index     +smallint + +(!) + + +label     +varchar + + +period_date     +date + + +is_current     +boolean + + +period_kind     +varchar + + +label_confidence     +numeric(5,2) + @@ -311,161 +428,357 @@ financial_statement_column:e->financial_statement_meta:w - - -1 -* + + +1 +* financial_statement_line - - - - -       financial_statement_line        - - -id -     -uuid - - -meta_id     -uuid - -(!) - - -line_code     -varchar - - -line_code_original_value     -varchar - - -line_code_confidence     -numeric(5,2) - - -line_code_modified     -boolean - - -line_name     -varchar - - -line_name_original_value     -varchar - - -line_name_confidence     -numeric(5,2) - - -line_name_modified     -boolean - + + + + +       financial_statement_line        + + +id +     +uuid + + +meta_id     +uuid + +(!) + + +line_code     +varchar + + +line_code_original_value     +varchar + + +line_code_confidence     +numeric(5,2) + + +line_code_modified     +boolean + + +line_name     +varchar + + +line_name_original_value     +varchar + + +line_name_confidence     +numeric(5,2) + + +line_name_modified     +boolean + + +row_index     +int + + +source_ref     +varchar + + +line_status     +statement_line_status + +(!) + + +line_code_text     +varchar + + +parent_row_index     +int + + +needs_review     +boolean + +(!) + + +is_confirmed     +boolean + +(!) + + + +financial_statement_line:e->statement_line_status:w + + financial_statement_line:e->financial_statement_meta:w - - -1 -* + + +1 +* financial_statement_value - - - -       financial_statement_value        - - -id -     -uuid - - -line_id     -uuid - -(!) - - -column_id     -uuid - -(!) - - -amount     -numeric(18,2) - - -string_value     -varchar - - -confidence     -numeric(5,2) - - -is_recognized     -boolean - - -is_modified     -boolean - -(!) - - -original_amount     -numeric(18,2) - - -modified_by     -uuid - - -modified_at     -timestamptz - + + + +       financial_statement_value        + + +id +     +uuid + + +line_id     +uuid + +(!) + + +column_id     +uuid + +(!) + + +amount     +numeric(18,2) + + +string_value     +varchar + + +confidence     +numeric(5,2) + + +is_recognized     +boolean + + +is_modified     +boolean + +(!) + + +original_amount     +numeric(18,2) + + +modified_by     +uuid + + +modified_at     +timestamptz + + +source_text     +varchar + + +needs_review     +boolean + +(!) + + +is_confirmed     +boolean + +(!) + + +confirmed_by     +uuid + + +confirmed_at     +timestamptz + financial_statement_value:e->app_user:w - - -1 -* + + +1 +* + + + + +financial_statement_value:e->app_user:w + + +1 +* financial_statement_value:e->financial_statement_column:w - - -1 -* + + +1 +* financial_statement_value:e->financial_statement_line:w - - -1 -* + + +1 +* + + + +document_issue + + + + +       document_issue        + + +id +     +uuid + + +document_id     +uuid + +(!) + + +kind     +document_issue_kind + +(!) + + +code     +varchar + +(!) + + +severity     +issue_severity + +(!) + + +message     +text + +(!) + + +line_ids     +jsonb + + +column_id     +uuid + + +column_index     +smallint + + +codes     +jsonb + + +field     +varchar + + +source     +issue_source + +(!) + + +relation     +varchar + + +expected     +numeric(18,2) + + +actual     +numeric(18,2) + + +created_at     +timestamptz + +(!) + + + + + + +document_issue:e->document_issue_kind:w + + + + +document_issue:e->issue_severity:w + + + + +document_issue:e->issue_source:w + + + + + +document_issue:e->document:w + + +1 +* + + + + +document_issue:e->financial_statement_column:w + + +1 +* diff --git a/model/entities-v5.19.md b/model/entities-v5.19.md new file mode 100644 index 0000000..3f4c293 --- /dev/null +++ b/model/entities-v5.19.md @@ -0,0 +1,211 @@ +# АРМ КА — описание сущностей модели данных v5.19 + +Источник истины по структуре — `data-model-v5.19.dbml` (полный). Диаграммы по частям — `diagrams/` (part01…09 svg/png; перерисовываются `make diagrams` из текущего DBML). Здесь — что каждая таблица значит для бизнеса, как связана с соседями и откуда взялась. + +## Что изменилось в v5.19 (решения владельца 17.10.2026, Р-1…Р-19; RSHB/arm-ka-docs#116) + +Change `openspec/changes/unified-statement-normalization`: формы ББ/ОФР нормализуются общим кодом сервисов, контракт +результата v2.0; ядро хранит результат как пришёл (`context/05-domain-rules.md` §11, §9.3, §9.5). + +| № | Изменение | Решение | +|---|---|---| +| 1 | `financial_statement_line`: снята уникальность `(meta_id, line_code)` (индекс остаётся неуникальным); новые `row_index`, `source_ref` (частичный уникальный `(meta_id, source_ref) WHERE source_ref IS NOT NULL`), `line_status` (enum `statement_line_status`: `ok` / `unreadable` / `missing` / `detail`), `line_code_text`, `parent_row_index`, `needs_review`, `is_confirmed` | Повтор кода — законный случай (один код, разные наименования; Р-4): хранятся все строки, чтения «по коду» идут через агрегат формы по коду (сумма строк `ok`, не хранится — DECISION-5). Строки без кода хранятся со статусом (Р-8, отменяет OQ-36 / OQ-141): `detail` — расшифровка под строкой с кодом, в §7, агрегаты, КФО и ЕХ не входит; `unreadable` / `missing` блокируют подтверждение, пока у них есть суммы (Р-13). Порядок и положение в источнике (`row_index`, `source_ref`) нужны экрану, чтобы показать строку и предупреждение у места. **Признак «строка создана правкой аналитика» — `row_index IS NULL`** (прежний признак `line_code_original_value IS NULL` не годится: у строк `unreadable` / `missing` / `detail` источника кода тоже нет); `line_code_original_value` — код из результата как пришёл, правками не меняется | +| 2 | `financial_statement_value`: `source_text`, `needs_review`, `is_confirmed`, `confirmed_by`, `confirmed_at` | `needs_review` **хранится** (пересматривает п. 4 v5.13): его считает сервис («подтверждение проверкой» §7 — Р-5, Р-7), ядро пересчитывает только после правок; подтверждение ячейки без правки аналитиком переживает пересчёт. Сырой текст источника — подсказка ячейки | +| 3 | `financial_statement_column`: `period_kind`, `label_confidence` | Относительные подписи бланка 66н («На отчётную дату», «На 31 декабря предыдущего года») — дата колонки от эффективной даты документа, ядро досчитывает после правки даты отчёта (Р-6); колонка без даты остаётся с `period_kind = none` | +| 4 | `financial_statement_meta`: `form_variant` (enum `statement_form_variant`), `needs_review_fields`, `header_source` (jsonb), `organization_name_original_value`, `organization_name_modified` | Упрощённая форма — свой набор соотношений §7 (`NULL` — документ до v5.19 или результат 1.x, набор полный); поля шапки, требующие проверки (сейчас — неуверенная дата отчёта); сырой текст и уверенность всех полей шапки результата (`<поле>_text`, `<поле>_confidence`) — подсказка у поля на экране; наименование организации правится, как ИНН | +| 5 | Новая таблица `document_issue` + enum `document_issue_kind`, `issue_severity`, `issue_source` | Предупреждения и нарушения §7 документа с уровнем и привязкой к строкам (`line_ids`), колонке (`column_id`, а вне колонок формы — `column_index`), полю шапки; у нарушения — `codes` (коды цели и членов); как пришли от сервиса или посчитаны ядром. Очищается при перезагрузке файла; при пересчёте после правки `relation` / `soft` заменяются целиком, `warning` сервиса сохраняются, кроме снимаемых правкой. **Единственный источник нарушений** для экрана и подтверждения: для результатов 1.x ядро один раз при приёме считает §7 копией `arm_statements` и пишет записи с `source = core`; формы до миграции не пересчитываются (база очищается, Р-24). Ведётся и у документов ОСВ — предупреждения листа `OSV_*` результата 2.0 | +| 6 | `recognition_job.statements_version` | Коммит копии общего кода `arm_statements`, которым посчитан результат (`service.statements_version`); расхождение с копией ядра при пересчёте — `info` | +| 7 | `document`: уникальность `(stored_file_id, document_type, osv_account_code)` — частичная, кроме `BALANCE` / `INCOME`; новая колонка `file_ordinal` + уникальный `(stored_file_id, file_ordinal)` | Вторая форма того же типа в файле хранится (Р-10, Р-15); при двух формах одного типа и периода ни одна не автоподтверждается, подтверждение второй при подтверждённой первой — 409 `DUPLICATE_FORM` (Р-18). ОСВ по счёту, расшифровка и прочий — по-прежнему один на тип на файл (§9.5 п. 2) | +| 8 | `related_group.crm_id` (`varchar`, nullable, unique) | ID ГСК/ГСЗ в ЕЦП CRM из форм `51-03`, `51-04`, `77-02` (AURORA-1619, arm-core#112, OQ-33); вводится вручную, импорт ЕЦП не заполняет (OQ-91); уникален, как `crm_id` ГК и ЮЛ | + +Остальные таблицы и enum — как в v5.18. Реализация — одна миграция в `arm-core`; перед выкладкой база очищается от форм +ББ/ОФР (решение владельца 18.10.2026, Р-24), поэтому миграция только добавляет колонки, таблицу и значения по умолчанию и +§7 по существующим формам не считает. Заполнение (миграция проходит и на непустой базе): `row_index` — `NULL` у строк, +созданных правкой (`line_code_original_value IS NULL`), у остальных — по возрастанию `line_code` в форме; `line_status = +ok`, `needs_review` значений — **НЕ** `values_ok`, `source_text = string_value`, `period_kind` — `absolute` при +`period_date`, иначе `none`, `header_source` — из `*_original_value`, `file_ordinal` — по `(created_at, id)` в файле; +`document_issue` пуст — у форм, оставшихся в неочищенной базе, нарушения до первой правки не показываются. Откат +гарантирован на пустой БД и на данных, которые v5.18 представляет без потери первичных данных и ручного ввода (без +повторов кода, строк без кода, двух форм одного типа в файле, `crm_id` групп и правленого наименования организации); на +таких данных `downgrade` останавливается с ошибкой, а производные колонки v5.19 и `document_issue` удаляет с записью в +журнал миграции. + +## Что изменилось в v5.18 (решения владельца 09.10.2026, RSHB/arm-ka-docs#90) + +| № | Изменение | Решение | +|---|---|---| +| 1 | `stored_file.consolidation_id` (`uuid`, nullable, FK → `consolidation`) + индекс `(legal_entity_id, consolidation_id)` | Файл загружается **в консолидацию** (период ГК) и становится основой её «рабочего стола» (полный состав стола — `context/05-domain-rules.md` §9.5 п. 11), общего для всех версий периода; `NULL` — загрузка без контекста (страница ЮЛ, монозаёмщики, API). Панель «Первичные документы» в версии показывает рабочий стол консоли для ЮЛ плюс файлы с документами, привязанными к версиям консоли. Что использует конкретная версия — по-прежнему `consolidation_document`; заморозка после `VERIFIED` — привязки и `ex_snapshot`. Привязка файла к **версии** (`consolidation_file`) отвергнута: рабочие версии периода делят первичку, а новая версия после `VERIFIED` подхватывает подтверждённое (`openspec/changes/console-files/design.md`) | +| 2 | Note `recognition_job.reply_deadline` | Без изменения структуры: срок ответа OCR — 20 мин (OQ-159) | + +Остальные таблицы и enum — как в v5.17. Реализация — миграция `0006` в `arm-core` (backfill `consolidation_id` по `consolidation_document`). + +## Что изменилось в v5.17 (решение владельца 07.10.2026, пересмотр OQ-31, RSHB/arm-ka-docs#53) + +| № | Изменение | Решение | +|---|---|---| +| 1 | `legal_entity.kpp` и `legal_entity_history.kpp` (`varchar`, nullable) | КПП юридического лица нужен (макет: колонка «КПП» на экранах `50`, `61`, поле форм `51-02`, `79`; фронтенд: «в ручке получения ЮЛ не приходит КПП»). Формат — 9 символов: 4 цифры, 2 символа (цифра или латинская A–Z), 3 цифры, иначе `INVALID_KPP`. У ИП (`entity_type = ИП`) КПП запрещён. Для ЮЛ необязателен: существующие записи и импорт без колонки остаются валидными. Уникален по-прежнему только `inn` (КПП — атрибут; сопоставление контрагентов ОСВ по ИНН не меняется). Изменение КПП — обычная правка со снимком в `legal_entity_history`. Отменяет п. 2 v5.14 («КПП не вводится») | + +Остальные таблицы и enum — как в v5.16. Реализация — миграция `0005` в `arm-core`. + +## Что изменилось в v5.16 (Срез 4, решения владельца 06.10.2026) + +| № | Изменение | Решение | +|---|---|---| +| 1 | Enum `counterparty_match_kind` (`INN`, `NAME`, `ALIAS`, `MANUAL`, `PROPAGATED`) и колонка `osv_entry.counterparty_match_kind` (nullable) | Способ сопоставления контрагента строки ОСВ хранится. `NULL` ⇔ `counterparty_id` `NULL`; `INN` / `NAME` / `ALIAS` — шаг автоматического резолва при маппинге (ИНН; точное имя; нормализованное имя ЮЛ или алиас); `MANUAL` — ручное сопоставление аналитиком; `PROPAGATED` — автоприменение по имени в версии (DOC-221278 п. 12–13). Вычислять на чтение нельзя: после ручного связывания появляется алиас, и ручное сопоставление неотличимо от `ALIAS`. Миграция `0004` заполняет колонку по уже смапленным строкам (ИНН без пробелов = `legal_entity.inn` → `INN`; `trim(name) = legal_entity.name` → `NAME`; остальные с `counterparty_id` → `ALIAS` — приближение: ручных сопоставлений до Среза 4 не было) | +| 2 | Семантика `consolidation_document` | Без изменения структуры: строка создаётся при подтверждении документа (по документам ЮЛ — членов периметра версий «в работе») и при добавлении ЮЛ в периметр (подтверждённые подходящие документы подхватываются); удаляется при замене/удалении файла и исключении ЮЛ из периметра. Для одной (ЮЛ, `document_type`, `osv_account_code`, период, признак нарастающего) привязан один документ — самый поздний `confirmed_at` (D-5 Среза 4) | +| 3 | Семантика `consolidation_lock` | Без изменения структуры: блокировка без истечения по времени — активна, пока её не снял владелец, администратор или не перехватил другой пользователь с `CONSOLIDATION_EDIT` по причине (OQ-119) | + +Пробелы квартальной склейки и сама сводная ОСВ по-прежнему не хранятся (DECISION-5). Остальные таблицы и enum — как в v5.15 (43 таблицы; enum — 28). Реализация — миграция `0004` в `arm-core`. + +## Что изменилось в v5.15 (Срез 2c, решения владельца 05.10.2026) + +| № | Изменение | Решение | +|---|---|---| +| 1 | `osv_data_type` + `NU` (после `BU`) | Строки налогового учёта из ОСВ «БУ, НУ» хранятся в `osv_entry` как есть (OQ-45); контракт результата допускал `NU` с v1.0, ядро раньше такой строки принять не могло | +| 2 | `app_user.email` — в нижнем регистре | Без изменения структуры: нормализация при записи (seed, администрирование), поиск по `lower(email)` сохраняется, уникальность — существующий UNIQUE по `email` (OQ-59). Expression-индекс `lower(email)` не вводится: сверка ORM↔DBML не умеет expression-индексы. Миграция `0003` приводит существующие email к нижнему регистру (при дублях по регистру — падает, слияние решает человек) | +| 3 | Семантика `stored_file.status`, `recognition_job.group_schema` | Без изменения структуры: `PROCESSING` ставит inbox по ack сервиса, `PROCESSED`/`ERROR` — цикл маппинга результата (OQ-67); маппинг выполняется отдельно от inbox (OQ-69); `group_schema` заполняет маппер; «Итого» в `osv_entry` не хранится (OQ-01) | +| 4 | Индексы `osv_entry (parent_entry_id)`, `osv_entry (recognition_job_id)`, `stored_file (current_recognition_job_id)` | Индексы для удаления файла и обхода дерева строк: без них проверки FK (в том числе self-FK `parent_entry_id`) при `DELETE /files/{id}` и очистке файла перед повторным маппингом шли seq scan'ом. Замер: DELETE документа 1000 строк 8,6 с → 16 мс на 200 тыс. строк. Обычные (не unique) индексы; закрывает OQ-117 (I-1 финального ревью Среза 2c) | + +Остальные таблицы и enum — как в v5.14 (43 таблицы, 27 enum). Реализация — миграция `0003` в `arm-core`. + +## Что изменилось в v5.14 (Срез 1, решения владельца 04.10.2026) + +| № | Изменение | Решение | +|---|---|---| +| 1 | `related_group_member.kind` + индекс `(legal_entity_id, kind) WHERE is_current` | ЮЛ состоит **одновременно в одной ГСК и одной ГСЗ** (но только в одной группе каждого вида). Отменяет ANL-030 / v5.8 («только в одной ГСК или ГСЗ»). `kind` — денормализация `related_group.kind` (равенство проверяет ядро при записи, тест на рассинхрон); индекс не выразить через join, триггер не выбран. Закрывает OQ-32 | +| 2 | КПП | **Не вводится** *(отменено в v5.17: КПП введён)*. Поле есть только в макете; у `legal_entity` его нет, импорт его не читает (закрывал OQ-31 до v5.17) | +| 3 | Семантика `company_group.crm_id`, `base_regional_office_id` | Без изменения структуры: `crm_id` = «iD ГК» выгрузки ЕЦП CRM, `base_regional_office_id` = «Базовый РФ ГК в CRM»; флаги «входит в ГСК/ГСЗ» выгрузки → членство в одной неявной ГСК/ГСЗ на ГК (правила — `05-domain-rules.md` §13) | +| 4 | `audit_action` + `LEGAL_ENTITY_CHANGE`, `USER_CHANGE` | Журнал изменений ЮЛ (создание/изменение/закрытие/перевод, алиасы) и пользователей (создание/деактивация администратором) — ТЗ 8.2; в v5.13 для них не было действия (были `GROUP_CHANGE`, `MEMBERSHIP_CHANGE`, `USER_ROLE_CHANGE`) | + +Остальные таблицы и enum — как в v5.13 (43 таблицы). Реализация — миграция `0002` в `arm-core`. + +## Что изменилось в v5.13 (ревью 30.09–01.10) + +| № | Замечание | Решение | +|---|---|---| +| 1 | Вместо RBAC «в коде» — роли **и** права | `permission` + `role_permission` возвращены; права только через роли, прямых назначений пользователю нет. Исключения — заведением роли | +| 2 | `osv_entry.parent_entry_id` не нужен — храним только строки с контрагентами | **Отложено.** Открыт вопрос: хранить ли строки уровня ACCOUNT для сверки «Σ контрагентов = итог счёта»; аналитика «Парсинг ОСВ» п.11 пока говорит «все строки» | +| 3 | `osv_group_schema` не особо нужна | Убрана. Схема шапки — `recognition_job.group_schema jsonb`; тип строки — `osv_entry.group_type` | +| 4 | `financial_statement_value.needs_review` непонятен | Убран: производное от `confidence` / `is_recognized`, порог может меняться | +| 5 | В `recognition_job` не нужны и `result`, и `raw_result` | Оставлен только `result_s3_object_id`. Сырой ответ ИС Сканирование — S3 `raw/{job_id}.json` по конвенции, в БД не регистрируется | +| 6 | `stored_file.fields_modified` — что это | Счётчик «исправлено полей» с карточки документа (v5.7). Убран — `COUNT(is_modified)` по данным | +| 6-бис | `document_type` не предусматривает несколько ОСВ или ББ+ОФР в одном файле | **Разделение файл / документ**: `stored_file` — физический файл, `document` — логическая единица (1:N). Чек-лист, проверки, подтверждение, FK разобранных данных, `consolidation_document`, `ex_snapshot_item` — на `document`. Замена (`superseded_by`) — файл целиком | +| 7 | `file_type`: убрать Р7 | Убран. `xls/xlsx/xlsb → tabular-parser`, `pdf/png/jpg → scan-gateway` | + +**Принципы модели (коротко):** +- Первичка не редактируется: файлы и распознанные значения хранятся как пришли; правки аналитика — рядом (`is_modified`, `original_*`) и в аудите. +- Производные консолидации в работе (сводная ОСВ, пары ВГО, шахматка) **не хранятся** — считаются запросом. Хранятся только ручные решения (корректировки, гудвилл, подтверждение КФО клиента). +- При верификации всё замораживается в Единое хранилище (ЕХ) как самодостаточный срез, который не зависит от таблиц консолидации. +- Единственный писатель в БД — ядро `arm-core`; сервисы распознавания видят только NATS и S3. +- Права — данные (`role_permission`), ограничения по ЗО/филиалу/статусу — policy-слой поверх прав. + +--- + +## Часть 01 — Группы компаний, организации, филиалы + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `regional_office` | Филиал банка: головной офис (ГО, `is_head_office`) и региональные филиалы (РФ). Определяет зону видимости пользователя и закрепление ГК. | ← `company_group.base_regional_office_id`, `legal_entity.regional_office_id`, `app_user.regional_office_id` | ТЗ 8.1 | +| `company_group` | Группа компаний (ГК) — верхний уровень справочника клиентов. Текущее состояние (SCD4). `crm_id` = «iD ГК» из ЕЦП CRM (NULL, если создана вручную Куратором; v5.14 — семантика по выгрузке), внутренний `code`, закрепление за филиалом (`base_regional_office_id` = «Базовый РФ ГК в CRM»), флаг архива. | → `regional_office`; ← `legal_entity`, `related_group`, `consolidation` | ТЗ 6.1, Прил. 2; макет | +| `company_group_history` | Прошлые снимки ГК: каждое изменение — новая строка с `valid_from/valid_to`. | → `company_group` | ТЗ 6.10, 8.2 | +| `legal_entity` | Юридическое лицо / ИП. Натуральный ключ — ИНН; **v5.17:** `kpp` — КПП (9 символов, nullable, только у ЮЛ, не ключ). `company_group_id` = текущая ГК; **NULL = монозаёмщик**. `close_date` — закрыто/архивировано. | → `company_group`, `regional_office`; ← `related_group_member`, `stored_file`, `document`, `osv_entry.counterparty_id`, `consolidation_member` | ТЗ 6.1, Прил. 2; ЕХ 6.1 | +| `legal_entity_history` | Прошлые снимки ЮЛ + вид события (создание, изменение, переход в другую ГК, закрытие, вывод из ГК) и дата вступления в силу. | → `legal_entity` | ТЗ 8.2; макет | +| `legal_entity_alias` | Словарь написаний контрагента: как имя встречается в ОСВ и нормализованный ключ для матчинга. Пополняется при ручном сопоставлении. | → `legal_entity` | ТЗ 6.5 | +| `related_group` | ГСК или ГСЗ внутри ГК; `kind` различает. Для ГСК формируется КФО. Иерархия не поддерживается. **v5.19:** `crm_id` — ID группы в ЕЦП CRM (вручную, уникален). | → `company_group`; ← `related_group_member`, `consolidation.related_group_id` | ТЗ 6.1, Прил. 2 | +| `related_group_member` | Состав ГСК/ГСЗ — история членства (SCD2). **v5.14:** ЮЛ может одновременно входить в **одну ГСК и одну ГСЗ** (не более одной группы каждого вида): колонка `kind` = `related_group.kind` (денормализация) и частичный уникальный индекс `(legal_entity_id, kind) WHERE is_current`. Решение владельца 04.10.2026, отменяет ANL-030. | → `related_group`, `legal_entity` | ТЗ 6.1; макет [79] | + +## Часть 02 — Пользователи, роли, права + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `app_user` | Пользователь АРМ. Аутентификация внешняя (РАИСА/SSO, `external_id`). Филиал (ГО/РФ), зона ответственности (ДКБ/ДМСБ/ДМ), табельный номер. Видимость данных = права роли + филиал + ЗО. | → `regional_office`; ← `user_role`, все `*_by` поля | ТЗ 8.1, Прил. 2 | +| `role` | Роли: seed из ТЗ 8.1 (КА РФ, КА ГО, РКА, КМ, Куратор ГО, Эксперт, Администратор; `is_system`) + роли, заведённые администратором для исключений. **v5.13:** код роли в бизнес-логике не проверяется — только права. | ← `user_role`, `role_permission` | ТЗ 8.1 | +| `permission` | **v5.13.** Атомарное право «объект × действие»: `DIRECTORY/DOCUMENT/CONSOLIDATION/KFO/EX/EXPORT/USERS/AUDIT` × `VIEW/EDIT/APPROVE/VERIFY/DELETE/EXPORT/ADMIN`. Seed, привязан к коду — администратор новые коды не создаёт. Право говорит «может»; policy-слой (ЗО, филиал, статус КФО) — «эту ли и сейчас ли». | ← `role_permission` | ТЗ Прил. 2, сноска 29 | +| `role_permission` | **v5.13.** Матрица доступов как данные: `effective_permissions(user) = ∪ permissions(roles(user))`. Редактируется администратором на экране «Изменение ролей и прав доступа»; история — `audit_event(ROLE_PERMISSION_CHANGE)`. Прямых прав у пользователя нет. | → `role`, `permission` | ТЗ Прил. 2; макет | +| `user_role` | Назначение ролей пользователю (M:N). История — в аудите. | → `app_user`, `role` | ТЗ 8.1 | + +## Часть 03 — Бухгалтерские справочники и правила + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `chart_of_account` | План счетов с иерархией, тип A/P/AP, признак забалансового. Seed — полный план 1С; нестандартные субсчета клиентов добавляет администратор. | ← `osv_entry.account_id`, `elimination_rule_account`, `required_document_rule` | ТЗ 6.2/6.5, Прил. 3/4 | +| `elimination_rule` | Правило элиминации из Прил. 3 (ББ) / Прил. 4 (ОФР) как данные: пара статей, сторона, мера, режим, НДС, акцизы. Версионируется. | ← `elimination_rule_account`, `correction.rule_id`; версия фиксируется в `consolidation_version` | ТЗ 6.4, Прил. 3/4 | +| `elimination_rule_account` | Счета/субсчета каждой стороны правила, включая исключения («76 кроме 76.7.1»). | → `elimination_rule`, `chart_of_account` | ТЗ Прил. 3/4 | +| `required_document_rule` | Какие документы обязательны по подходу. **v5.13:** сверяется с `document` (тип + счёт + период), а не с файлом: выполнено = есть `document CONFIRMED` у каждого ЮЛ периметра. | → `chart_of_account` | ТЗ 5.1, 6.2, сноска 12 | + +## Часть 04 — Файлы, документы, S3, задания распознавания + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `s3_object` | Реестр всех объектов в S3: бакет-роль, ключ, размер, sha256, срок жизни, ссылка на архив в data lake. Единая точка ретеншна (5 лет по ТЗ 8.4) и дедупа. | ← `stored_file`, `recognition_job` | ТЗ 6.2, 8.4; ЕХ 8.3 | +| `stored_file` | **v5.13: только физический файл** одной загрузки: чей (ЮЛ), формат, hash, кто/когда загрузил, статус обработки (`UPLOADED → PROCESSING → PROCESSED / ERROR / SUPERSEDED`), версия при замене. **v5.18:** `consolidation_id` — консолидация, в контексте которой файл загружен (основа «рабочего стола консоли», общего для версий периода; полный состав — `context/05-domain-rules.md` §9.5 п. 11; `NULL` — без контекста). Что внутри файла — `document`. Замена — файл целиком со всеми документами. | → `legal_entity`, `consolidation`, `s3_object`, `recognition_job`; ← `document`, `recognition_job` | ТЗ 6.2, 8.2; UC-01…05 | +| `document` | **v5.13, новая.** Логический документ внутри файла: ББ, ОФР, ОСВ по счёту (`osv_account_code`), расшифровка, прочее. Период, отчётная дата, где в файле (`locator`: лист / страницы), статус проверки (`RECOGNIZED / NEEDS_CHECK / CONFIRMED / ERROR`), результаты проверок периода и контрагента, кто подтвердил. Один файл → N документов (PDF с ББ+ОФР; xlsx с несколькими ОСВ). Создаёт ядро при маппинге результата `recognition_job`. На документ ссылаются разобранные данные, чек-лист, консолидация и ЕХ. **v5.19:** `file_ordinal` — индекс документа в `documents[]` результата (у всех документов); формы ББ/ОФР одного типа в файле могут повторяться (ОСВ по счёту, расшифровка, прочий — один на тип); предупреждения и нарушения — `document_issue`. | → `stored_file`, `legal_entity`, `app_user`; ← `osv_entry`, `financial_statement_meta`, `document_issue`, `consolidation_document`, `ex_snapshot_item` | ТЗ 6.2, 5.1, 8.2; макет (чек-лист, «Подтвердить данные») | +| `recognition_job` | Одна попытка распознать/распарсить файл. **Единственная точка обмена с NATS** (outbox/inbox, дедуп по `result_msg_id`, DLQ). Хранит сервис и версию, **одну** ссылку на нормализованный JSON-результат в S3 (v5.13), метрики, и **схему группировок шапки ОСВ по листам** (`group_schema jsonb`, v5.13 — вместо `osv_group_schema`). **v5.19:** `statements_version` — версия копии общего кода нормализации, которой посчитан результат форм. | → `stored_file`, `s3_object`; ← `osv_entry`, `financial_statement_meta` | ТЗ 6.2, 6.10; DECISION-4 | + +## Часть 05 — Разобранные ОСВ + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `osv_entry` | Строка ОСВ: документ (счёт + период), владелец, тип строки по схеме шапки (`group_type`, v5.13), уровень и родитель (`parent_entry_id` — **открыт вопрос, замечание 2**), код счёта и резолв в план счетов, сырое имя/ИНН контрагента, резолв в ЮЛ и способ сопоставления (`counterparty_match_kind`, v5.16), шесть сумм, тип данных (БУ/валюта/кол-во), «подакцизный» для 90-го. Ручные правки: `is_modified`, `original_values`, `modified_by`. Признак ГСК/ГК/Внешний не хранится — выводится из периметра версии; способ сопоставления контрагента хранится (`NULL` ⇔ контрагент не сопоставлен). | → `document`, `recognition_job`, `legal_entity` ×2, `chart_of_account` | ТЗ 6.2/6.5, Прил. 2; «Парсинг ОСВ» | + +## Часть 06 — Распознанные формы ББ/ОФР + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `financial_statement_meta` | Шапка распознанной формы (1:1 с `document` BALANCE/INCOME): ИНН и наименование как в документе, отчётная дата, единица измерения, реквизиты. Ревью формы (`reviewed_by/at`) и акцепт КА непройденных логических проверок с комментарием. Подтверждение документа — `document.confirmed_by`. **v5.19:** `form_variant` (полная / упрощённая — набор соотношений §7), `needs_review_fields`, `header_source` (сырой текст и уверенность полей шапки), правка наименования (`organization_name_original_value`, `organization_name_modified`); результат проверок §7 и предупреждения хранятся в `document_issue` как пришли от сервиса, ядро пересчитывает их только после правок. | → `document`, `recognition_job`, `app_user`; ← `column`, `line` | ТЗ 6.2/6.3; FNR01; макет | +| `financial_statement_column` | Колонки-периоды формы с датой — нужны для межформенной проверки (1370 на начало года) и гудвилла. **v5.19:** `period_kind` (абсолютная / относительная подпись бланка / без даты), `label_confidence`. | → `meta`; ← `value` | ТЗ 6.3 п.7, 6.7 | +| `financial_statement_line` | Строка формы как в источнике: код (или его сырой текст) и наименование как распознано. **v5.19:** все строки источника — с кодом (`ok`, повторы кода тоже) и без (`unreadable`, `missing`, `detail` — расшифровка под родителем `parent_row_index`); порядок `row_index` (`NULL` ⇔ строка создана правкой аналитика), положение `source_ref`, `needs_review` неуверенного кода, подтверждение кода `is_confirmed`. Раскладка экрана — по коду «как на бумаге». | → `meta`; ← `value`, `document_issue.line_ids` | ТЗ 6.2, Прил. 2 | +| `financial_statement_value` | Число в ячейке (строка × колонка): сумма, уверенность, `is_recognized`, ручная правка с исходным значением. **v5.19:** подсветка — только `needs_review` (хранится: уверенность, `is_recognized` и §7 сведены сервисом, ядро пересчитывает после правок); `source_text` — сырой текст источника; `is_confirmed` / `confirmed_by/at` — подтверждено без правки. (v5.13 этот флаг убирала как производный.) | → `line`, `column`, `app_user` | ТЗ 6.2, Прил. 2 | +| `document_issue` | **v5.19, новая.** Предупреждение или нарушение §7 документа (у формы; у ОСВ — предупреждения листа): вид (`warning` / `relation` / `soft`), код и уровень (`error` / `info`), сообщение, привязка к строкам (`line_ids`), колонке (`column_id` или `column_index`) и полю шапки, источник (`service` / `core`); для нарушения — соотношение, `codes`, `expected`, `actual`. Экран показывает их у места. | → `document`, `financial_statement_column` | change `unified-statement-normalization` (design, шаг 8) | + +## Часть 07 — Консолидация + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `consolidation` | Логическая консолидация: **ГК** + отчётный период. ГСК фиксируется/создаётся при верификации по фактическому периметру. Уникальна по (ГК, год, квартал). **v5.18:** файлы, загруженные в консолидацию, — `stored_file.consolidation_id` (основа рабочего стола, общего для версий периода; полный состав — `context/05-domain-rules.md` §9.5 п. 11). | → `company_group`, `related_group`; ← `consolidation_version`, `stored_file` | ТЗ 6.10, 7.2 | +| `consolidation_version` | Версия консолидации: номер, статус, сценарий, подход, гудвилл, инициатор и его офис (маршрут), кто утвердил / верифицировал, решение УОБ, отклонения от Методики, версия правил элиминации, ссылка на предыдущую. После VERIFIED — read-only. | → `consolidation`, `app_user`, `regional_office`, self; ← всё в частях 07–08 | ТЗ 5.1, 6.4, 6.10; макет | +| `consolidation_status_history` | Каждый переход статуса: из → в, кто, когда, комментарий (обязателен при возврате). | → `consolidation_version`, `app_user` | ТЗ 6.10, 8.2 | +| `consolidation_lock` | «Взять в работу»: кто редактирует версию, перехват с причиной (v5.16: без истечения по времени, OQ-119). Из макета — ANL-036. | → `consolidation_version`, `app_user` | макет | +| `consolidation_member` | Периметр версии — зафиксированный список ЮЛ; `is_head_entity` для гудвилла. | → `consolidation_version`, `legal_entity` | ТЗ 6.1, 6.10 | +| `consolidation_document` | **v5.13:** какие **документы** (не файлы) участвуют в версии. Чек-лист = `required_document_rule` × периметр − `consolidation_document`. Переиспользование одного документа в нескольких версиях/периодах, монозаёмщики из ЕХ. | → `consolidation_version`, `document` | ТЗ Прил. 2; ЕХ 7 | + +*Не хранятся (DECISION-5), считаются запросом по версии:* сводная ОСВ, признак контрагента ГСК/ГК/Внешний, пары ВГО, шахматка, результирующая таблица ВГО. + +## Часть 08 — Корректировки, гудвилл, результат КФО + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `correction` | Ручная корректировка ВГО: пара (правило + компания 1 + компания 2 — естественный ключ), ББ/ОФР, строки и суммы по Дт и Кт, дочерние строки для распределения, причина, обязательный комментарий, статус, номер версии (история — в аудите). КФО = свод − Σ корректировок. | → `consolidation_version`, `elimination_rule`, `legal_entity` ×2, `chart_of_account` ×2, self | ТЗ 6.4, 6.8, Прил. 9 | +| `goodwill` | Строка расчёта гудвилла: головная × дочерняя, доля владения, ФВ по 58.01, УК (1310) и добавочный капитал (1350 — вопрос методологам), гудвилл, «включить в КФО», комментарий. | → `consolidation_version`, `legal_entity` ×2, `app_user` | ТЗ 6.7, Прил. 3 п.2, Прил. 8 | +| `kfo_result` | Шапка результата КФО: когда посчитан, заморожен ли, ЧА и ВБ, выручка, прибыль, расхождения, пороги, обоснование, итог гудвилла. | → `consolidation_version` (1:1); ← `kfo_result_line` | ТЗ 6.4, 6.8, Прил. 10 | +| `kfo_result_line` | Строка консолидированного ББ/ОФР по коду ФСБУ 4/2023: сумма → авто-элиминация → корректировки → гудвилл → итог; «в т.ч. ВГО», источник, расхождение, комментарий. | → `kfo_result`; ← `kfo_result_line_company` | ТЗ 6.4, Прил. 10; макет | +| `kfo_result_line_company` | Разрез строки КФО по компаниям периметра: сумма и доля. | → `kfo_result_line`, `legal_entity` | ЕХ 6.6; макет | +| `client_kfo_line` | Сценарий «подтверждение КФО клиента»: по строке — значение и ВГО от клиента (ручной ввод), значение и ВГО банка, расхождение, причина. | → `consolidation_version`, `app_user` | ТЗ 5.1, 6.10; макет | + +## Часть 09 — Комментарии, аудит, Единое хранилище + +| Сущность | Что это | Ключевые связи | Откуда | +|---|---|---|---| +| `comment` | Полиморфный комментарий к объекту (документу, ячейке шахматки по ключу пары, строке КФО). | → `app_user`; `object_type + object_id` без FK | ТЗ 6.3/6.4; макет | +| `audit_event` | Журнал действий (append-only): кто, когда, действие, раздел, объект, версия консолидации, before/after, результат, IP/браузер. Покрывает историю правок распознанных данных, корректировок, выгрузок, импорта справочников, входов, **изменений матрицы прав** (v5.13), подтверждения документов, **изменений ЮЛ и пользователей** (v5.14: `LEGAL_ENTITY_CHANGE`, `USER_CHANGE`). | → `app_user`, `consolidation_version` | ТЗ 8.2, Прил. 2; макет | +| `ex_snapshot` | Срез ЕХ: одна ГСК на одну отчётную дату, версия, статус (актуальный/архив), когда и кем верифицирован и записан. Самодостаточен — на консолидацию ссылается только данными. | → `related_group`, `company_group`, `app_user`, self; ← `member`, `item` | ЕХ ТЗ 6.2, 6.4, 5.3 | +| `ex_snapshot_member` | Периметр среза на дату: ЮЛ с ИНН и наименованием на момент верификации. | → `ex_snapshot`, `legal_entity` | ЕХ ТЗ 6.3 | +| `ex_snapshot_item` | Содержимое среза. Два вида: исходный документ → ссылка на **`document`** (v5.13; скачать — через его файл) и всё производное → `payload` jsonb. | → `ex_snapshot`, `document`, `legal_entity` | ЕХ ТЗ 5.2, 6.7 | + +--- + +## Как читать модель по сценарию «сформировать КФО» + +1. **Справочники** (01–03): ГК → ЮЛ → ГСК/ГСЗ; план счетов; правила элиминации; что обязательно загрузить. +2. **Создание консолидации** (07): `consolidation` от ГК + период → `consolidation_version` → `consolidation_member` (периметр). +3. **Загрузка** (04): `stored_file` + `s3_object` → `recognition_job` (queued → NATS → сервис → результат в S3 → done) → ядро создаёт **`document`** на каждый ББ / ОФР / ОСВ по счёту, найденный в файле. +4. **Разбор** (05–06): ОСВ → `osv_entry`; ББ/ОФР → `financial_statement_meta/column/line/value`. Аналитик проверяет, правит, подтверждает **документ**; логические проверки — на лету; сопоставляет контрагентов (→ `osv_entry.counterparty_id`, `legal_entity_alias`). Чек-лист закрывается по `document CONFIRMED`. +5. **Консолидация** (вычисляется): сводная ОСВ, пары ВГО, шахматка — из `osv_entry` × периметр × правила. Аналитик вносит `correction`, заполняет `goodwill`, при сценарии подтверждения — `client_kfo_line`. Результат — `kfo_result(_line)`. +6. **Согласование** (07): `consolidation_status_history` по маршруту; блокировка `consolidation_lock`. Что можно — решает `role_permission` + policy-слой по статусу/ЗО. +7. **Верификация → ЕХ** (09): `ex_snapshot` + `member` + `item` (документы по ссылке, остальное — payload). Версия → read-only. Всё это время — `audit_event`. + +## Открытые вопросы после v5.14 + +- **Замечание 2** (`osv_entry.parent_entry_id`, какие строки ОСВ хранить) — отложено; затрагивает и аналитику «Парсинг ОСВ» п.11. +- Замена по-документно (одна ОСВ из файла с пятью) — сейчас не предусмотрена, замена файлом целиком. Если понадобится — `superseded_by` переезжает в `document`. +- Статус `document.RECOGNIZED` (ручная проверка не требуется по порогу) — подтвердить с аналитиками, нужен ли, или все документы проходят через `NEEDS_CHECK → CONFIRMED`. diff --git a/openspec/changes/unified-statement-normalization/.openspec.yaml b/openspec/changes/unified-statement-normalization/.openspec.yaml new file mode 100644 index 0000000..b699b88 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-17 diff --git a/openspec/changes/unified-statement-normalization/design.md b/openspec/changes/unified-statement-normalization/design.md new file mode 100644 index 0000000..2140b05 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/design.md @@ -0,0 +1,1394 @@ +# Единый алгоритм нормализации ББ/ОФР (scan-gateway и tabular-parser) — дизайн + +Issue: arm-ka-docs#116. Статус: **финальная версия** — целевая картина по шагам 1–9 согласована с владельцем +(решения Р-1…Р-19, 17.10.2026) и прошла три раунда независимого ревью (`review-2026-10-17.md`, +`review-2026-10-17-closure.md`, `review-2026-10-17-blind.md`, `review-2026-10-17-closure-2.md`, +`review-2026-10-17-blind-2.md`). Спека (дельты OpenSpec, контракт v2.0, §7/§9/§11 в `05-domain-rules.md`, DBML v5.19) +написана 18.10.2026 (решения S-1…S-25), прошла ревью вслепую (`review-2026-10-18-spec.md`, находки K-1…K-25) и +доработана по нему (решения владельца Р-20…Р-22, решения S-26…S-52 ниже), прошла повторное ревью вслепую +(`review-2026-10-18-spec-2.md`, находки L-1…L-30) и доработана по нему (решения владельца Р-23…Р-26, решения +S-53…S-74 ниже). Где текст дизайна выше расходится с решениями Р-20…Р-26 и S-n, действуют они и спека. + +## Цель + +Нормализация результата распознавания форм ББ/ОФР в scan-gateway (PDF → ИС Сканирование) и в tabular-parser +(Excel) должна идти по одной цепочке шагов. У каждого шага — определённый результат, который передаётся в следующий +шаг; результаты между шагами одинаковы для обоих сервисов (где это возможно). Все нестыковки собраны в таблицы +различий по каждому шагу. + +Зачем: нормализовать каждый шаг; убрать бойлерплейт в arm-core (повторная дедупликация, пересчёт проверок, статус, +который ядро не может взять из результата); улучшить поддерживаемость (один словарь — одно место); улучшить +пользовательский опыт заказчика (старые ошибки распознавания AURORA-1406, 1462, 1511, 1517, 1519, 1522, 1523, 1524, 1634 +— свойство конвейера, а не отдельные баги, см. issue #116). + +## Как работает сейчас: цепочка шагов + +Каждый шаг описан так: **вход → что делает scan-gateway → что делает tabular-parser → выход → различия**. +Код: scan-gateway `api/src/core/worker.py`, `gateway/`, `normalizer/ocr/`; tabular-parser `api/src/core/worker.py`, +`parser/book.py`, `parser/readers/`, `parser/statements/`. Правила: `05-domain-rules.md` §7, §11, §11.1, §12. + +### Шаг 0. Загрузка и доставка до сервиса + +**Вход:** файл пользователя. + +Общая часть (arm-core): файл принимается в карточку ЮЛ или в консолидацию (`primary_files.upload_one`): тип — по +расширению и сигнатуре, дедуп по `(ЮЛ, sha256)`, файл — в S3, запись `stored_file` и `recognition_job` (`queued`). +Конвейер: xls / xlsx / xlsb → `tabular`, PDF / PNG / JPG → `image`. Публикатор outbox собирает `recognition-request` +(`job_id`, `file_id`, `pipeline`, `file_type`, `original_name`, ссылка S3, `reply_deadline`, `attempt`) и публикует +в subject конвейера (`files.tabular_uploaded` / `files.img_uploaded`). Срок ответа по умолчанию: OCR — 20 мин, +парсер — 5 мин; нет ack 30 с — повтор, после трёх повторов — `dlq`, файл `ERROR`. + +В сервисе (одинаково): запрос проверяется (JSON-объект, MAJOR версии 1, свой `pipeline`, схема; иначе +`SCHEMA_MISMATCH` без ack); повтор `job_id` в работе — только повторный ack; ack `processing` до чтения S3; файл +читается из S3 (не прочитан — `FILE_READ_ERROR`). scan-gateway дополнительно: бакет должен быть его единственным; +`reply_deadline` истёк до отправки — `TIMEOUT`. + +**Выход:** байты исходного файла + запрос (`file_type`, `original_name`, `reply_deadline`). + +**Различия:** нет, кроме сроков ответа и проверки бакета. + +### Шаг 1. Подготовка данных + +**Вход:** байты файла. + +#### 1.1 Разбор и чтение сырых данных + +**scan-gateway** — разбор делает ИС Сканирование: +1. Файл пишется во «Входящие» горячей папки как `.` (нет расширения / папка недоступна — + `RECOGNITION_FAILED`). +2. ИС кладёт в «Исходящие» `/` один или несколько json — по одному на распознанный комплект отчётности. +3. Опрос каждые `SCAN_POLL_SECONDS` (5 с); набор готов, когда есть `*.json` и он не менялся `SCAN_SETTLE_SECONDS` + (2 с); срок — `min(reply_deadline, запись + SCAN_TIMEOUT_SECONDS)` (1200 с), иначе `TIMEOUT`. +4. Склейка: каждый json разбирается независимо, по возрастанию имени → `{job_id, files: [{name, response, + parse_error}]}`; сохраняется в S3 `raw/{job_id}.json` **до** нормализации; затем (только при `SCAN_CLEANUP`) + папка очищается. + +Содержимое одного json (по `fixtures/ocr/*.json`, §11): корень `{file_uid, error {code, message}, data_ocr}`, +успех — `error.code == ""`; `data_ocr`: поля шапки `label`, `organization`, `inn`, `units`, `date` — каждое +`{form_name, confidence}` (сырой OCR-текст), **одна шапка на json**; `table_structure[]` — таблицы форм `{label +{form_name, confidence}, rows[{row_index, cells[{column_index, value, confidence}]}]}`, `column_index` не обязательно +подряд. Наблюдается: json с таблицами содержит ББ и ОФР одного периода под общей шапкой. **Допущение владельца +(17.10.2026):** несколько периодов в одном файле ИС отдаёт разными json; образца нет — проверить на первом таком файле. + +**tabular-parser** — чтение делает парсер: +1. Файл больше `PARSE_MAX_BYTES` (50 МБ) — `UNSUPPORTED_FORMAT`; `PARSE_TIMEOUT_SECONDS` (120 с) — от начала разбора. +2. Формат — по сигнатуре байтов (OLE2 → XLS, zip с `xl/workbook.bin` → XLSB, иной zip → XLSX, прочее — + `UNSUPPORTED_FORMAT`); несовпадение с `file_type` — предупреждение; лимиты распакованного zip и площади + объединённых ячеек. +3. Книга → листы `Sheet`: непустые ячейки (текст, значение, числовой формат), объединённые диапазоны, уровни + группировки, габариты. Библиотека упала — `UNREADABLE`; xlsb без стилей и группировки — предупреждение. + +#### 1.2 Базовая разметка + +**scan-gateway** — разметку сделала ИС, шлюз её принимает: ошибочный json (`parse_error`, непустой `error.code`, корень +не объект) — предупреждение `<имя>: <код>: <сообщение>` и пропуск; все json ошибочны — `error` с кодом первого. Шапка +json: ИНН — только цифры, организация, дата (двойники + разбор), единицы; уверенность ИС у каждого поля; +предупреждения уровня файла «ИНН не распознан» / «ИНН распознан неуверенно» / «дата отчёта не разобрана». Каждая +таблица `table_structure[i]` — кандидат в документ; тип — по `label` (`rules/statement_labels.yaml`). + +**tabular-parser** — разметка по содержимому (§12 п. 1, 6): вид листа (признак ведомости в первых 30 строках — +ОСВ, отдельный путь; заголовок формы или ОКУД — форма; маркер «Форма … с.2», шапка «Код» с периодами, колонка кодов +или 1–2 кода сразу после листа формы — продолжение; иначе прочее с предупреждением). Формы на листе: строки с +заголовком («Бухгалтерский баланс», «Отчет о финансовых результатах», «…о прибылях и убытках») или ОКУД — +кандидаты; без кодов между ними сливаются; заголовок после кодов — новая форма. Тип по ОКУД (0710001 → `BALANCE`, +0710002 → `INCOME`, прочие 0710xxx и «Пояснения» → `DISCLOSURE`), заголовок спорит с ОКУД — ОКУД и предупреждение. +Шапка **своя у каждой формы** в границах блока: организация, ИНН (без ИНН аудитора), ОКПО, ОКОПФ, ОКФС, единицы, +дата, адрес; найдено — 100, нет — `null`; предупреждения «в шапке не найдена организация» / «…единица измерения». +Листы-продолжения присоединяются к последней открытой форме — но решение требует раскладки колонок (шаг 2). + +**Выход:** список кандидатов в документы форм — признак типа, шапка, сырые строки таблицы (строки × ячейки). + +**Различия (шаг 1):** + +| № | | scan-gateway | tabular-parser | +|---|---|---|---| +| 1 | Кто размечает | ИС Сканирование | парсер, по содержимому | +| 2 | Единица разметки | json (комплект одного периода) → таблицы | книга → листы → блоки форм | +| 3 | Шапка | одна на json, общая для его таблиц | своя у каждой формы | +| 4 | Тип формы | `label` таблицы (уточнение по кодам — шаг 6) | ОКУД, иначе заголовок | +| 5 | Уверенность | у каждого поля шапки и каждой ячейки, 0–100 | нет (найдено — 100) | +| 6 | Многостраничная форма | одна таблица = одна форма, склейки нет | листы-продолжения присоединяются (шаг 6) | +| 7 | Ячейка | сырой OCR-текст + `confidence` | текст, значение и числовой формат | + +### Шаг 2. Раскладка таблицы + +**Вход:** сырые строки таблицы одной формы. + +**scan-gateway** (`layout.py`, `rows.parse_table_rows`, §11.1 п. 3): шапка — строка с ячейкой, начинающейся на +«код»; наименование — ячейка «наименование», иначе ближайшая заполненная слева от кода; колонки значений — правее +кода, где в тексте четыре цифры года или текст начинается на «на » / «за ». **Повторная шапка внутри таблицы +перезаписывает раскладку** (число колонок изменилось — предупреждение; значения строки берутся по позиции, лишние +отбрасываются, недостающие — «ячейки нет»). Без шапки раскладка выводится по данным: код — столбец с наибольшим +числом значений из справочника кодов, наименование — левее с наибольшим числом текстов, значения — правее, где +суммы не реже мусора; шапка синтезируется, колонки без дат, предупреждение. Ни шапки, ни столбца кода — таблица +пропускается («колонка кодов не найдена»). Колонка: `label` — текст шапки, `period_date` — разбор даты после замены +букв-двойников, только если текст «размером с дату»; нет даты — «колонка N без даты». + +**tabular-parser** (`columns.find_layout`, §12 п. 2–4): колонка кодов — столбец с наибольшим числом кодов (4–5 +цифр, первые четыре в диапазоне формы), кодов ≥ 3 (у продолжения ≥ 1) и доля среди непустых ≥ порога; нет — +документ без строк, «колонка кодов не найдена». Строка заголовка — последняя над первым кодом с подписью «Код», иначе +последняя с двумя и более периодами правее кодов (или одним не голым годом); двухстрочный заголовок склеивается; +строка нумерации «1 2 3 4 5» — часть заголовка. Колонки-периоды — правее кодов с распознанным периодом по словарю +`statement_periods.yaml`; подпись без периода («Изменение», «%») пропускается, одно предупреждение со списком. +Наименование — левее кодов с наибольшим числом текстов, кроме «Пояснения». Без заголовка (продолжение): колонки +значений — где в строках с кодами есть числа, подпись пустая, дата `null`. + +**Выход:** раскладка формы — столбец кода, столбец наименования, колонки значений `[{label, period_date}]`, первая +строка данных, строки, которые не данные (шапка, нумерация). + +**Различия (шаг 2):** + +| № | | scan-gateway | tabular-parser | +|---|---|---|---| +| 1 | Что считается кодом при поиске столбца | столбец — по справочнику кодов (`infer_layout`), строка — любое целое (`int(text)`) | 4–5 цифр в диапазоне формы (словарь) | +| 2 | Повторная шапка внутри таблицы | перезаписывает раскладку | не ищется (одна раскладка на блок) | +| 3 | Дата колонки | «размером с дату» + двойники + `parse_date` | словарь периодов `period_end` | +| 4 | Без шапки | синтез шапки, колонки без дат, предупреждение | колонки по числам, подпись пустая | +| 5 | Строка нумерации «1 2 3 4 5» | не распознаётся (становится строкой данных, AURORA-1517) | часть заголовка, в данных пропускается | +| 6 | Подпись без периода | колонка значений, «без даты» | колонка пропускается, предупреждение | + +### Шаг 3. Отбор строк данных + +**Вход:** раскладка + сырые строки. + +**scan-gateway** (`rows.py`, §11.1 п. 4): склеенная строка («1500\n1700» с N частями в суммах) разлепляется на N +(OQ-156, предупреждение-счётчик). Классификация по ячейке кода: пустой код — пропуск (есть суммы — счётчик «строк без +кода с суммами пропущено»); **нечисловой код («4 400\nI #4У», «ИЧ,250») — пропуск** (счётчик «строк с нечисловым +кодом пропущено»); целое число — строка данных (вид `ROW` / `TOTAL` / `BALANCE` по наименованию — дальше не +используется). Повтор кода — остаётся первая, предупреждение «код N повторяется: оставлена первая строка». Пропущенные +строки **не восстанавливаются**. + +**tabular-parser** (`lines.collect_lines`, §12 п. 5): пустые строки пропускаются; код — 4–5 цифр в диапазоне, иначе +строка «без кода»: нумерация «1 2 3 4 5» — пропуск; без кода с ненулевыми суммами — счётчик «строк без кода с суммами +пропущено»; повтор кода — остаётся первая, код в `duplicates`. + +**Выход:** строки данных формы — сырой код, сырое наименование, ячейки значений по колонкам; счётчики отброшенных. + +**Различия (шаг 3):** + +| № | | scan-gateway | tabular-parser | +|---|---|---|---| +| 1 | Код-мусор | отдельный счётчик «нечисловой код» | не отличается от «без кода» | +| 2 | Склейка двух строк в одну | разлепляется (OQ-156) | нет такого случая | +| 3 | Повтор кода | предупреждение текстом | `checks.duplicate_line_codes` | +| 4 | Отброшенная строка | только счётчик; строка исчезает (AURORA-1634, 1462) | только счётчик; строка исчезает | + +### Шаг 4. Значения ячеек + +**Вход:** ячейки значений строки. + +**scan-gateway** (`amounts.parse_amount`, `document._value`, §11.1 п. 7): пробелы удаляются первыми; запятая / +точка как разделитель тысяч по шаблонам (`4,200`, `837.994`); запятая → точка; скобки → минус; из строки выбрасывается +всё, кроме цифр, `+`, `-`, `.`; больше одной точки — все удаляются; больше двух знаков после точки — `amount = null`, +текст в `string_value`. Прочерк / пусто — `null`. `is_recognized` = `confidence == 100` **и** при разборе не +выбрасывались символы. Ячейки под колонкой нет — `amount = null`, `confidence = 0`, `is_recognized = false`. + +**tabular-parser** (`lines._value`, `statement_money`, `number_format.display_amount`, §12 п. 5): число с числовым +форматом — **как показывает формат ячейки** (масштабирующие запятые → тысячи, число знаков, секции и условия, +округление half-up; формат не разобран — сырое значение и одно предупреждение на документ); иначе `to_money`: +`Decimal`, скобки → минус, прочерк / пусто → `null`, нечисловой текст → `string_value`, `is_recognized = false`. +`confidence` всегда 100. Счётчики дробных значений: при «тыс.» и ≥ 10 % дробных — предупреждение. + +**Выход:** `StatementValue {column_index, amount, string_value, confidence, is_recognized}`. + +**Различия (шаг 4):** + +| № | | scan-gateway | tabular-parser | +|---|---|---|---| +| 1 | Предобработка текста | OCR-мусор: пробелы, двойники, тысячи, скобки `}` / `]` | числовой формат ячейки Excel | +| 2 | Смысл `is_recognized` | уверенность 100 ∧ ничего не выброшено | текст разобрался как число | +| 3 | «42831 1» | пробел удалён → 428311, «чистое» (AURORA-1511) | не бывает | +| 4 | Единицы | не проверяются | предупреждение о дробных при «тыс.» | +| 5 | Пустая ячейка там, где была колонка | `null`, `confidence` ИС | `null`, 100 | + +### Шаг 5. Код, наименование, редакция строки + +**Вход:** строка данных с сырым кодом и наименованием. + +**scan-gateway** (`document._line`, `_choose_edition`, §11.1 п. 6): редакция формы — по дате отчёта, иначе по самой +поздней дате колонок, иначе по кодам (предупреждение); код очищается до букв, цифр, пробела и точки; **наименование — +из справочника редакции по коду** (`api/rules/statement_lines.yaml` шлюза), уверенность кода и имени = уверенность +ячейки кода, если текст чистый и код в справочнике, иначе `min(conf, 99)`; кода нет в справочнике — имя из OCR со +своей уверенностью, код `min(conf, 99)`, предупреждение «код N из редакции …», если он есть в другой. + +**tabular-parser** (`lines.collect_lines`, `_line_name`): код как найден (4–5 цифр); **наименование — из файла**: +ячейка колонки наименований, если пуста — ближайшая текстовая левее кодов, кроме «Пояснения»; уверенность кода 100, +имени 100 (пусто — `null`). Редакции и справочника нет. + +**Выход:** `StatementLine {line_code, line_code_confidence, line_name, line_name_confidence, values}`. + +**Различия (шаг 5):** + +| № | | scan-gateway | tabular-parser | +|---|---|---|---| +| 1 | Источник наименования | справочник по коду (AURORA-1406, 1522) | файл | +| 2 | Редакция (66н / ФСБУ 4) | определяется, влияет на имена | не определяется | +| 3 | Справочник строк | свой `statement_lines.yaml` (не совпадает с ядром) | нет | +| 4 | Уверенность кода | от ИС, срезается до 99, если не в справочнике | всегда 100 | + +### Шаг 6. Сборка документа + +**Вход:** раскладка, строки, шапка кандидата. + +**scan-gateway** (`document.build_document`, `_resolve_type`): тип — по `label`; все коды одной группы и противоречат +`label` — тип по кодам с предупреждением; `label` не сопоставлен — по большинству кодов. Колонки — из первой шапки. +`locator = file:;table:`. Шапка — поля json (общая). + +**tabular-parser** (`statement._document`, `merge`): лист-продолжение присоединяется к последней открытой форме, +если число колонок сумм совпало и тип в маркере не противоречит (слабое продолжение с 1–2 кодами — ещё и коды из +диапазона формы); не присоединился — одинокое продолжение с пустой шапкой и предупреждение. `locator = +sheet:,`. `DISCLOSURE` и форма без колонки кодов — без `columns` / `lines`. + +**Выход:** `StatementDocument {document_type, locator, header, columns, lines}` (без `checks`). + +**Различия (шаг 6):** + +| № | | scan-gateway | tabular-parser | +|---|---|---|---| +| 1 | Тип формы | `label`, сверка с кодами | ОКУД / заголовок, с кодами не сверяется | +| 2 | Склейка страниц | нет | листы-продолжения | +| 3 | `locator` | `file:…;table:i` | `sheet:A,B` | + +### Шаг 7. Самопроверки + +**Вход:** документ. + +**scan-gateway:** не делает; `checks` отсутствует («не проверялось»). + +**tabular-parser** (`statements/checks.run_statement_checks`, §12 п. 7): соотношения §7 по каждой колонке из +`statement_checks.yaml`: жёсткие → `relations_failed`, мягкие → `soft_relations_failed`; допуск ±1, округление +0,001 % уводит жёсткое в мягкие; `abs_terms`; «родитель = Σ подстрок» только при непустой детализации (AURORA-1527); +повторы кодов → `duplicate_line_codes`; предупреждения текстом на каждое нарушение. + +**Выход:** `StatementChecks` или ничего. + +**Различия (шаг 7):** есть только у парсера; словарь §7 у парсера и ядра — две копии; ядро результат парсера **не +читает**, считает заново. + +### Шаг 8. Статус и предупреждения + +**Вход:** документы и накопленные предупреждения. + +**scan-gateway** (`_Build.fully_recognized`, `_normalize_file`, `normalize`): документ «полностью распознан», если ни +одного предупреждения, все ячейки `is_recognized`, уверенность кода и имени 100, ИНН и организация с уверенностью 100, +дата распознана. Файл — все документы распознаны ∧ нет предупреждений файла. Конверт `done` ⇔ все файлы распознаны ∧ +ни один json не ошибочен; иначе `needs_check`; ни одной таблицы — `error RECOGNITION_FAILED`. Предупреждения — +плоские строки `: table:: <текст>` в `metrics.warnings`. + +**tabular-parser** (`statement._is_ok`, `book.parse`): документ `ok` ⇔ колонка кодов найдена ∧ не одинокое +продолжение ∧ есть колонки ∧ в шапке организация или ИНН ∧ `checks` есть ∧ нет жёстких нарушений ∧ нет повторов +кодов; `DISCLOSURE` всегда `ok`. Книга `done` ⇔ все документы `ok` ∧ ни один лист не потерян; книга без листов ОСВ и +форм — `INVALID_DOCUMENT`. Предупреждения — строки `sheet=<имя> <текст>` в `metrics.warnings`; на статус не влияют. + +**Выход:** `ResultStatus` конверта + `metrics.warnings[]`. + +**Различия (шаг 8):** + +| № | | scan-gateway | tabular-parser | +|---|---|---|---| +| 1 | Критерий `done` | всё с уверенностью 100 и без предупреждений | структура найдена, проверки сошлись | +| 2 | Влияние предупреждений | любое → `needs_check` | не влияют | +| 3 | Статус документа | нет (только конверт) | внутренний `ok`, в контракт не попадает | +| 4 | Формат предупреждений | `: table:: …` | `sheet=<имя> …` | +| 5 | Привязка предупреждения | к файлу | к документу (по префиксу) | + +Общее для обоих: статус один на конверт; предупреждения — свободный текст без привязки к строке или ячейке; ядро и +экран не могут показать, что именно и где не так. + +### Шаг 9. Результат + +Одинаково: `RecognitionResult` (`schema_version`, `job_id`, `file_id`, `status`, `documents`, `metrics`) → S3 +`results/{file_id}/{job_id}/result.json` → конверт в `files.tabular_parsed` с `Nats-Msg-Id = result::`. +У шлюза в конверте ещё `raw_s3_key`. + +### Что с результатом делает ядро (для полноты картины) + +- `result_mapping.map_file` → `statement_mapping.build_statement`: **повторно** отбрасывает повторы колонок, кодов и + значений (защита от обоих производителей), пишет `financial_statement_*` как пришло. +- `statement_status` (D-6): `RECOGNIZED` ⇔ конверт `done` ∧ проверки §7 **пересчитаны ядром** и сошлись ∧ ИНН не + `MISMATCH` ∧ период известен ∧ все ячейки с уверенностью 100 и `is_recognized` ∧ есть строки ∧ нет предупреждений + маппинга; иначе `NEEDS_CHECK`. `checks` парсера не читаются; `metrics.warnings` сервиса на статус не влияют. + `RECOGNIZED` сразу автоподтверждается. +- `GET …/statement`: §7 считается заново по словарю ядра (копия словаря парсера); недостающие строки достраиваются + из `statement_lines.yaml` ядра (не совпадает со справочником шлюза); предупреждения в ответе отсутствуют. +- Экран: ячейка подсвечивается только по `confidence < 100` или `is_recognized = false`; нарушения §7 — списком без + привязки к ячейкам; предупреждения сервиса и маппинга нигде не показываются. + +Бойлерплейт и потери, которые порождены различиями шагов 2–8: три копии словарей (§7 в парсере и ядре, строки в +шлюзе и ядре); двойная дедупликация; пересчёт проверок; статус, который ядро собирает заново из косвенных признаков; +три несвязанных канала качества (уверенность ячейки, проверки §7, предупреждения), из которых пользователь видит +полтора. + +## Целевая картина + +Каждый шаг ниже: **вход = выход предыдущего шага**, единый выход для обоих сервисов, список «различие → к чему +приводим → что меняется в scan-gateway → что меняется в tabular-parser». Решения владельца собраны в конце +(Р-1…Р-19, все приняты 17.10.2026). Текст прошёл три раунда независимого ревью (`review-2026-10-17*.md` рядом с этим +файлом); находки внесены. Имена полей — рабочие, уточняются при написании спеки. + +Принципы, из которых выведены решения по строкам: + +1. **Один выход на шаг.** Код, зависящий от источника, живёт только в шаге 1 (адаптеры «json ИС → кандидат», «лист + Excel → кандидат»). Шаги 2–8 — общий код с общими словарями (форма общего кода — Р-1). Единственное место, где + общий код смотрит на источник, — разбор запятой и точки в сумме (шаг 4). +2. **Ничего не выбрасывать молча.** Строка, колонка или ячейка, которую не удалось истолковать, идёт дальше со + статусом, а не превращается в счётчик; пользователь её видит и исправляет. Положение в источнике (`ref`), порядок + строк (`row_index`) и сырой текст ячейки (`text`) доезжают до базы и экрана. Подтвердить документ, не разобрав + строки с суммами без кода, нельзя (Р-13). +3. **Качество — структурно и с привязкой к месту:** уверенность ячейки (от источника), `is_recognized` (разбор числа), + статус строки, нарушения §7 по ячейкам, предупреждения с уровнем и привязкой. Уверенность, `is_recognized` и §7 + сервис сводит в один признак `needs_review` у значения, строки и поля шапки (шаг 8) и из них выводит статус + документа. Ядро при загрузке не пересчитывает ни проверки, ни статус, ни `needs_review`: хранит их как пришли и + добавляет только предупреждения `info` по своему словарю имён. После правок аналитика ядро считает тем же общим + кодом, добавляя своё знание: правка или подтверждение снимает `needs_review`, акцепт снимает жёсткие нарушения, + квартал периода, сверка ИНН и наименований с справочником — условия `RECOGNIZED`. +4. **Один словарь — одно место:** проверки §7 (полный и упрощённый наборы), периоды, заголовки форм, упорядоченные + перечни кодов форм по редакциям и вариантам (без наименований), диапазоны кодов — в общем коде сервисов; + соответствие «код → наименование» — только в ядре, сервисы его не знают и отдают распознанный текст (Р-3). Порядок и + состав редакций ядро берёт из своей копии общего кода, словарь ядра хранит только имена; `sync-statements --check` + сверяет, что все коды словаря ядра есть в перечне. +5. **Очистка OCR (§11.1) — не отдельный шаг, а первая стадия типовых разборов текста** в общем коде, там, где уже + известен смысл ячейки: дата шапки (шаг 1), подписи колонок (шаг 2), код строки и разлепление склеенных строк + (шаг 3), сумма (шаг 4). Буквы-двойники («О» → 0, «З» → 3, «б» → 6, «S» → 5, «I», «l», «|» → 1) заменяются в датах, + подписях, кодах и суммах (Р-11, отменяет §11.1 п. 1 и решение № 22 среза 3a «только даты»): сумма с заменой + получает `is_recognized = false`, код с заменой считается неуверенным (эффективная уверенность ниже 100), а дальше + их судьбу решают контрольные соотношения §7 и структурная проверка кода. В суммах крайние «|» (след границы + таблицы: «| : 122477») убираются, а не заменяются. ИНН двойники не трогают: только цифры, как сейчас — иначе + «3|1 21 2|» из aep_3 даёт лишние единицы и `MISMATCH` с ЮЛ. Разбор вызывается для любой ячейки без `value`; у Excel + он срабатывает только на текстовых ячейках и почти всегда ничего не меняет. Из `normalizer/ocr/` шлюза `text`, + `dates`, `amounts` становятся общими разборами, `layout`, `rows`, `header`, `document` — шагами 2–6 общего кода. + +### Шаг 1 — Подготовка данных + +**Решения владельца:** 1) кто размечает — различие остаётся; 2) на выходе одинаковый контракт; 3) шапка своя у +каждой таблицы; 4) тип формы — единый подход; 5) уверенность есть и у парсера, всегда 100; 6) парсер склеивает +продолжения в шаге 1; 7) ячейка — один формат. + +**Вход:** байты файла (шаг 0). + +**Единый выход:** список кандидатов в форму на файл, `FormCandidate`: + +- `source` — `ocr` | `excel`; +- `locator` — откуда взят: `file:<имя json>;table:` (как в §11 и golden-эталонах) / `sheet:[,]`; +- `type_hint` — `BALANCE` | `INCOME` | `DISCLOSURE` | `null`, по единому правилу (п. 4); +- `evidence` — ОКУД и заголовок как ячейки (для сверки с кодами в шаге 6); +- `header` — поля `header` контракта (`organization_name`, `inn`, `report_date`, `units`, `okud`, `okpo`, `okopf`, + `okfs`, `address`), каждое — ячейка или `null`; +- `rows` — плотная сетка строк × ячеек; все страницы и листы формы уже в одной сетке; пустая позиция — `null`; +- `segment_breaks` — номера строк сетки, с которых начинается новый лист Excel (у OCR пусто): граница листа — всегда + граница сегмента раскладки (шаг 2); +- `warnings` — предупреждения шага (формат — шаг 8). + +Ячейка `Cell` одна для шапки и таблицы: `text` (как показывает источник), `value` (число или дата, если источник их +знает; у OCR — `null`), `confidence` 0–100 (у Excel — 100), `ref` — позиция в источнике: OCR — `row:`, +Excel — `<лист>!`. Уникальность в документе требуется от `ref` строки (шаг 3), не ячейки. + +- **1. Кто размечает** — остаётся, фиксируется полем `source` + - scan-gateway: без изменений + - tabular-parser: без изменений +- **2. Единица разметки** — файл → список кандидатов; у OCR каждая таблица ИС — кандидат, у Excel — каждый блок формы на листе; шаги 2–8 работают только с кандидатом (Р-1 — форма общего кода) + - scan-gateway: адаптер «таблица json → кандидат» + - tabular-parser: адаптер «блок формы на листе → кандидат» +- **3. Шапка** — у каждого кандидата своя; поля = поля `header` контракта, каждое — `Cell`; разбор текста поля — общий код по `text`: ИНН → только цифры, без двойников; дата → словарь периодов (шаг 2 п. 3), который включает все шаблоны сегодняшнего `dates.py` шлюза; единицы → словарь. Уверенность и сырой текст каждого поля доезжают до контракта (`*_confidence`, `*_text`) + - scan-gateway: шапку json копирует в каждую свою таблицу + - tabular-parser: добавляет сырой текст и уверенность 100 +- **4. Тип формы** — одно правило: ОКУД → заголовок по одному словарю → `null`; окончательно — по кодам строк в шаге 6, у обоих + - scan-gateway: `label` таблицы становится заголовком; `statement_labels.yaml` уходит в общий словарь + - tabular-parser: добавляет сверку по кодам (шаг 6) +- **5. Уверенность** — поле `Cell.confidence`; Excel всегда 100 + - scan-gateway: без изменений + - tabular-parser: добавляет поле +- **6. Многостраничная форма** — одна форма = одна сетка на выходе шага 1; строки продолжения дописываются в конец сетки по маркеру «Форма … с.2» или по позиции листа, граница листа запоминается в `segment_breaks`; тип из маркера сверяется с `type_hint`, расхождение — отдельный кандидат + предупреждение `error`. Лист без маркера и без шапки присоединяется, только если его коды в диапазоне открытой формы (как `_weak_blocker` сейчас) и, при трёх и более кодах, число колонок сумм совпадает с формой; иначе — отдельный кандидат с предупреждением `error`. Раскладка у каждого сегмента своя (шаг 2 п. 2), поэтому разная ширина колонок или объединённые ячейки на листе-продолжении без шапки не сдвигают значения + - scan-gateway: без изменений + - tabular-parser: склейка переезжает до раскладки; `join_blocker` сохраняет проверки по типу, диапазону кодов и числу колонок +- **7. Ячейка** — один формат `Cell`; Excel: `text` — как показывает числовой формат, `value` — число по формату (tp#42; формат не разобран — сырое значение + предупреждение); OCR: `text` сырой, `value` `null`, `confidence` от ИС + - scan-gateway: обёртка над `value` + `confidence` ИС + - tabular-parser: отображаемый текст и значение считаются здесь, а не в шаге 4 + +### Шаг 2 — Раскладка таблицы + +**Вход:** `FormCandidate` (сетка, `segment_breaks`, `type_hint`). + +**Единый выход:** `TableLayout` — сегменты сетки (участки между повторными шапками и границами листов), у каждого: +столбец кода, столбец наименования, колонки значений `[{col, label, label_confidence, period_end, period_kind}]`, +сопоставленные с первым сегментом по порядку; роль каждой строки сетки (`title` | `header` | `numbering` | `data`). + +**Диапазоны кодов** (словарь, границы уточняются в спеке): ББ 1100–1799, ОФР 2100–2999, отчёт об изменениях +капитала 3100–3699, отчёт о движении денежных средств 4100–4499, отчёт о целевом использовании 6100–6499, +пояснения 5000–5999. «Кодовые диапазоны» для поиска столбца — объединение этих отрезков (годы 2000–2099 и +четырёхзначные суммы на 1–6 вне отрезков кодами не считаются). «Диапазон столбца» — отрезок, в который попадает +большинство кодов найденного столбца; им проверяются разлепление строк (шаг 3), слабые продолжения и наследование +столбца в сегментах. + +- **1. Столбец кодов** — одно правило: код — текст без пробелов, переносов и краевой пунктуации («1220 ,» → 1220), после замены двойников (порядок: убрать пробелы и переносы → двойники → проверка; «|» → 1 только если результат попадает в кодовый диапазон: «| 140» → 1140), 4–5 цифр (допускается `.0`), первые четыре в кодовых диапазонах. Многострочная ячейка — код, если каждая её часть код. (а) Столбец, над данными которого стоит подпись «Код» / «Коды» / «Код строки» (словарь подписей) **и под которой доля кодов среди непустых ячеек ≥ 0,6 или кодов ≥ 3**; несколько кандидатов — столбец с наибольшим числом кодов; берётся последняя такая подпись над первой строкой с кодом. Защита обязательна: в выгрузках 1С под подписью «Коды» титульного блока стоят ОКУД, дата, ОКПО, ИНН, ОКОПФ (12267 у АО, 12300 у ООО — по первым четырём цифрам это «коды баланса»), ОКЕИ: один код из девяти, доля 0,11. Документ из двух строк остаётся документом: два из двух, доля 1,0. (б) Подписи нет → столбец с наибольшим числом ячеек-кодов, кодов ≥ 3, доля ≥ 0,6 (так парсер сейчас); нет — «колонка кодов не найдена». Статусы строк с нечитаемым или пустым кодом — новое поведение для обоих: шлюз сегодня такие строки пропускает со счётчиком + - scan-gateway: столбец по подписи получает защиту по доле; запасной подсчёт — по кодовым диапазонам, как сейчас; классификация строк `int(text)` → статусы + - tabular-parser: добавляет проход по подписи до подсчёта; диапазон формы → кодовые диапазоны +- **2. Повторная шапка и границы листов** — одно правило: новый сегмент открывает строка с признаком шапки («Код» / та же сигнатура) **или** граница листа из `segment_breaks`; у сегмента своя раскладка: по его шапке, а без шапки — столбец кодов по правилу п. 1, и если сегмент не первый и кодов в нём меньше трёх, ослабленный поиск: столбец с наибольшим числом кодов из диапазона столбца первого сегмента, минимум один (так `relaxed` + `_weak_blocker` парсера сейчас: лист «_2» НПЗ с единственной строкой 2400, у которого столбец кодов в другой колонке, чем на листе 1); колонки значений без шапки — по числам в строках с кодами, сопоставление с первым сегментом по порядку; число не совпало — предупреждение `error`, значения по позиции; столбец кодов не найден ни так, ни так — предупреждение `error` сегмента, строки сегмента не теряются (роль `data`, статусы по шагу 3) + - scan-gateway: как сейчас, через общее правило + - tabular-parser: бывший «лист-продолжение со своей раскладкой» становится сегментом; бывшая проверка числа колонок в `join_blocker` — предупреждение сегмента +- **3. Дата колонки** — один словарь периодов (`statement_periods.yaml`), **объединяющий** сегодняшний yaml парсера и шаблоны `dates.py` шлюза: «На 31 марта 2025 г.», «За январь – март 2025 г.», «31.12.2024», «30/09/25», «30092025», «За 9 месяцев 2025 г.», «за I квартал 2025 г.», «1 полугодие 2026», «с 01.01.2025 по 31.03.2025», «2024», двузначный год, хвост «0:00:00», клетки «2|0|2|4|»; строки `test_dates.py::test_parses_real_forms` шлюза — обязательные тесты словаря. Результат — `period_end` и `period_kind`: абсолютные подписи → `absolute`; относительные («На отчётную дату», «На 31 декабря предыдущего года», «На 31 декабря года, предшествующего предыдущему», «За отчётный период», «За аналогичный период прошлого года») → `relative:<вид>`, дата считается от **эффективной даты документа**: дата отчёта из шапки, иначе самая поздняя абсолютная дата колонок; нет ни той ни другой → `period_end = null`, `period_kind` остаётся относительным, ядро досчитает после правки даты отчёта; подписи нет → `period_kind = none`. Подпись OCR с уверенностью ниже 100, разобранная в дату, подтверждается, если дата равна одной из ожидаемых (эффективная дата, минус год, минус два года) — иначе `error` «дата колонки неуверенна». Перед разбором — очистка двойников OCR. Относительные подписи — Р-6: это бланки по приказу 66н без впечатанных дат, у шлюза сейчас «колонка 0 без даты: На отчетную дату» + - scan-gateway: `parse_date` → словарь, его шаблоны переезжают в словарь + - tabular-parser: добавляет очистку двойников (no-op), относительные подписи и шаблоны шлюза +- **4. Строка заголовка и её отсутствие** — одно правило (обе ветки парсера сохраняются): строка заголовка — последняя над первой строкой с кодом, где в столбце кодов стоит подпись «Код»; иначе последняя над первой строкой с кодом с двумя и более подписями-периодами правее кодов (или одной, не голым годом); двухстрочный заголовок склеивается. Роль `title` — только строки выше найденной строки заголовка (титульный блок «Дата 30 | 9 | 2025» Межениновской); в сегменте без заголовка роли `title` нет, строки выше первого кода с содержимым идут в данные (шаг 3). Заголовка нет ни у одного сегмента документа → колонки значений первого сегмента = столбцы правее кода, где в строках с кодами стоят числа; `label` пустая, `period_end` `null`, `period_kind = none`; одно предупреждение `error` «строка заголовка не найдена». Заголовок есть хотя бы у одного сегмента — остальные сегменты берут его колонки (п. 2) без предупреждения: листы 2–5 ББ НПЗ без шапки остаются `done` + - scan-gateway: синтез шапки уходит (он эквивалентен); добавляет ветку «заголовок по периодам» + - tabular-parser: добавляет предупреждение для документа без единого заголовка +- **5. Строка нумерации «1 2 3 4 5»** — одно правило `is_numbering_row` → роль `numbering`, в данные не попадает + - scan-gateway: добавляет (AURORA-1517) + - tabular-parser: как сейчас +- **6. Подпись без периода** — Р-6: подпись — период по словарю (абсолютный или относительный, п. 3) → колонка с датой; подпись из словаря исключений («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») → пропуск с предупреждением `info`; иначе, если в столбце есть числа в строках с кодами (OCR-мусор вроде «| | г» с уверенностью 17 в aep_3, обрезанная или самодельная подпись) — колонка остаётся с подписью как есть, `period_end = null`, `period_kind = none`, предупреждение `error` с привязкой к колонке, документ `needs_check`; аналитик ставит дату колонке через PATCH или подтверждает после сверки с источником; иначе пропуск. Ядру колонка без даты не мешает для периода формы (эффективная дата, п. 3), но межформенная проверка и гудвилл «на отчётную дату» по такой колонке не считаются, пока дата не проставлена. Пропуск числовой колонки с нераспознанной подписью отвергнут: для OCR это тихая потеря периода с возможным автоподтверждением + - scan-gateway: добавляет словарь исключений и относительные периоды + - tabular-parser: перестаёт молча пропускать неизвестные числовые колонки; добавляет относительные периоды +- **7. Столбец наименования** — одно правило: подпись «Наименование (показателя)» в строке заголовка → этот столбец; иначе столбец левее кодов с наибольшим числом текстов, кроме столбца «Пояснения»; наименование строки — ячейка этого столбца, пусто → ближайшая текстовая ячейка левее кода, кроме «Пояснения» + - scan-gateway: «наименование», иначе слева от кода → общее правило + - tabular-parser: `_name_column` / `_line_name` → общее правило + +### Шаг 3 — Отбор строк данных + +**Вход:** `FormCandidate` + `TableLayout`. + +**Единый выход:** `RawLine[]` — по строке на каждую строку сетки с ролью `data`, у которой есть содержимое: код, +число или непустой текст в колонке значений с уверенностью ниже 100 (признак потерянной суммы: aep_3 row 8 «[190 :» +с «Ё», «" :»). У строки: `row_index` (порядковый номер в документе), `ref` (уникален в документе; у разлепленных строк +`row:#`), ячейка кода, `code_status`, ячейка наименования, ячейки значений по колонкам раскладки сегмента, +`parent_ref` (для `detail`). Статусы (`ok` | `unreadable` | `missing` | `detail`): + +- `ok` — код разобран; +- `unreadable` — ячейка кода содержит хотя бы одну цифру, но кодом не разобралась («ИЧ,250», «изо :», «[190 :»); + ячейка кода без единой цифры («}», «:») считается пустой; +- `detail` — Р-8: строка без кода с содержимым, стоящая под строкой с кодом (в том же сегменте или в предыдущих + сегментах документа — родитель продолжается через разрыв страницы), — расшифровка («в том числе: …», «из них …», + «Оборудование к установке», «Товар (41.01)»); родитель — ближайшая строка с кодом выше. Сервисы её отдают, ядро + хранит, в проверки §7, агрегаты и критерий `done` она не входит, экран по умолчанию не показывает. Отменяет OQ-36 / + OQ-141 («строки без кода не отдаются»); OQ-149 («восстановление строк по наименованию») остаётся открытым; +- `missing` — строка без кода с содержимым, над которой во всём документе нет строки с кодом (кода не было или он + потерян в самом начале таблицы: «Выручка» aep_3, где 2110 уехал в ячейку значения); строки роли `title` сюда не + попадают. + +Строка без кода и без содержимого (заголовок раздела «АКТИВ», «в том числе:» без сумм) — не строка. Счётчиков +отброшенных нет — есть статусы. Повтор кода — не статус: по правилам отчётности один код может стоять у нескольких +строк с разными наименованиями, все они `ok`. Строка формы с пустой ячейкой кода под другой строкой с кодом попадёт в +`detail` и будет скрыта; её значения не войдут в сумму раздела, нарушение §7 подсветит родителя (цель соотношения +отсутствует при непустых членах — нарушение, шаг 7), аналитик раскроет расшифровки и назначит код (Р-13) — осознанная +цена Р-8. + +- **1. Код-мусор** — статус `unreadable`, строка сохраняется с сырым текстом кода (AURORA-1462: «ИЧ,250» видна, аналитик назначает 1250). Разбор кода — по правилу шага 2 п. 1 + - scan-gateway: счётчик → статус + - tabular-parser: отличает «нет кода» от «не разобран» +- **2. Склейка двух строк в одну** — одно правило, выполняется **до** разбора кода: ячейка кода содержит N частей через перевод строки и ячейки значений — N частей → N строк (OQ-156) с `ref` `row:#`; разлепление делается, если хотя бы одна часть кода разобралась как код диапазона столбца («44 30\nI |» aep_6 не множится: 4430 не в диапазоне ББ), нечитаемая часть становится строкой `unreadable`; число частей не совпало или ни одна часть не код — одна строка, многострочное значение → `amount = null`, `string_value` = текст, `is_recognized = false`. Значения разлепленных строк получают `is_recognized = false` и нуждаются в покрытии проверками §7 (Р-17: сошлись — подсветки нет, иначе `needs_review`); у Excel не срабатывает + - scan-gateway: как сейчас, через общее правило; разлепление при нечитаемой части — новое; статус документа после разлепления — по общему правилу, а не безусловный `needs_check` + - tabular-parser: правило общее, no-op +- **3. Повтор кода** — Р-4: повтор кода — законный случай (один код, разные наименования), не ошибка и не предупреждение. Все строки сохраняются как есть со статусом `ok`; текстовое предупреждение и `checks.duplicate_line_codes` уходят. Для проверок §7, подтверждения и всего, что идёт после, значения одного кода идут суммой (см. «Что уходит из ядра»; договорённость ДБД в AURORA-1431; закрывает AURORA-1406). Неверно прочитанный код, случайно совпавший с существующим, ловит структурная проверка кода: повтор, у которого хотя бы одна копия с уверенностью ниже 100, — `needs_review` у этой строки (Р-16) + - scan-gateway: «оставлена первая» → все строки + - tabular-parser: `duplicates` уходит +- **4. Отброшенная строка** — строки не отбрасываются: `unreadable` / `missing` / `detail` идут дальше со статусом, `row_index` и `ref` (AURORA-1634, 1462); ядро не достраивает вместо них пустую шаблонную строку молча; подтвердить документ, пока у `unreadable` / `missing` есть суммы, нельзя (Р-13). Отмена OQ-36 / OQ-141 — Р-8 + - scan-gateway: счётчики → статусы + - tabular-parser: счётчики → статусы; строки без кода с суммами начинают отдаваться + +### Шаг 4 — Значения ячеек + +**Вход:** ячейки значений `RawLine` по колонкам раскладки (`Cell` или `null`). + +**Единый выход:** `StatementValue {column_index, amount, string_value, text, confidence, is_recognized}`: `text` — +сырой текст источника всегда, как `Cell.text`; `string_value` — как сейчас, только когда сумма не разобрана +(`amount = null`, непустой текст); `needs_review` досчитывается в шаге 8. + +- **1. Предобработка текста** — у ячейки после шага 1 либо есть `value`, либо только `text`. `value` — число → `amount = value`, квантование до 2 знаков half-up, не больше 16 целых цифр (`numeric(18,2)`), иначе `amount = null`, `string_value` = текст, `is_recognized = false` и предупреждение; `value` — дата или логическое → `amount = null`, `string_value` = текст, `is_recognized = false`; `value` нет → один `parse_amount(text, source)` для обоих: двойники (Р-11), пробелы и тонкие пробелы, минус «−» (U+2212) как знак, скобки → минус, причём `}` и `]` принимаются как закрывающая скобка (OQ-147), прочерк → `null`; разделители: у `ocr` запятая — тысячи при группе из трёх цифр (AURORA-1405, sg#35) и точка — тысячи при группах из трёх цифр («837.994», sg#37), текст с тремя и более знаками после разделителя — `amount = null`, `string_value` = текст (решение № 30 среза 3a сохраняется); у `excel` запятая и точка — десятичный разделитель («4,200» = 4,20, «1.500» = 1,5, как `to_money` сейчас); «лучшая догадка» из цифр ограничена 16 цифрами, иначе `null`; многострочное значение в строке с однострочным кодом → `amount = null`, `string_value` = текст, `is_recognized = false`; Excel-ячейки «число текстом» идут тем же путём. Разбор ОСВ (`to_money` в `classify.py`) общим `parse_amount` не заменяется — он остаётся за пределами этой работы + - scan-gateway: `parse_amount` переезжает в общий код и получает квантование, предел длины, `}` / `]`, «−», тонкие пробелы + - tabular-parser: `to_money` для форм заменяется общим `parse_amount` с `source = excel` +- **2. Смысл `is_recognized`** — один: текст разобран как число без выброшенных и исправленных символов (замена двойника — исправление; `}` / `]` вместо скобки — тоже); `confidence` — отдельно, от источника; оба признака сервис сводит в `needs_review` (шаг 8, «Подтверждение проверкой»), экран подсвечивает по нему + - scan-gateway: отвязывает от `confidence` + - tabular-parser: как сейчас +- **3. «42831 1»** — в `parse_amount` пробел допустим только как разделитель тысяч (группы ровно по три цифры); иначе `amount` — лучшая догадка (цифры подряд), `is_recognized = false`, сырой текст — в `text` (AURORA-1511, 1519) + - scan-gateway: меняется через общий код + - tabular-parser: то же правило, у Excel почти не срабатывает +- **4. Единицы** — одно правило: единицы «тыс. руб.» и дробных ≥ 10 % → предупреждение документа `info` (как у парсера сейчас, документ остаётся `done`); то же сочетание у книги xlsb без числовых форматов — `error`: вероятен масштаб ×1000 (рубли с копейками, которые формат переводил в тысячи), а §7 сходится при любом масштабе + - scan-gateway: добавляет + - tabular-parser: как сейчас; xlsb-сочетание — новое +- **5. Пустая ячейка** — Р-2: позиции нет, текст пуст или прочерк → `amount = null`, `string_value = null`, `is_recognized = true`, `confidence` ячейки (нет ячейки — 100), собственной подсветки нет. По `fixtures/ocr` ИС отдаёт ячейку на каждую позицию каждой строки: пустые — пустой текст с уверенностью 100; непрочитанные — пустой текст с уверенностью 0, и такие нуждаются в покрытии по общему правилу шага 8 (снимается покрытием или подтверждением ячейки аналитиком); позиция отсутствует только при несовпадении колонок сегмента, что покрыто предупреждением `error` шага 2 п. 2 + - scan-gateway: «ячейки нет → 0, не распознано» уходит + - tabular-parser: как сейчас + +### Шаг 5 — Код, наименование, редакция + +**Вход:** `RawLine` (статус, `row_index`, `ref`, ячейки кода и наименования) + `StatementValue[]`. + +**Единый выход:** `StatementLine {row_index, ref, line_status, line_code, line_code_text, line_code_confidence, +line_name, line_name_confidence, parent_ref, values}`: `line_code` — разобранный код (4–5 цифр) или `null` при +`unreadable` / `missing` / `detail`; `line_code_text` — сырой текст ячейки кода; `line_code_confidence` — от источника, +а у кода с заменой двойника — ниже 100 (неуверенный); `line_status` = `code_status` шага 3; `parent_ref` — у `detail`. +Несколько строк с одним кодом — обычные строки `ok`, порядок как в файле. + +- **1. Источник наименования** — Р-3: сервисы отдают распознанный текст наименования как есть, с уверенностью источника; соответствие «код → наименование» — только в ядре и только когда нужно (показ справочного имени, достройка шаблонных строк, сверка для неуверенных кодов — Р-16). Экран показывает справочное имя, файловое — рядом (AURORA-1406, 1522) + - scan-gateway: имя из `statement_lines.yaml` → имя из OCR + - tabular-parser: как сейчас +- **2. Редакция (66н / ФСБУ 4)** — в сервисах редакция нужна только для упорядоченного перечня кодов (подтверждение кода структурой, шаг 8) и выбирается по эффективной дате документа (шаг 2 п. 3), без даты — проверяются обе редакции. Наименования и достройка шаблонных строк — ядро по его словарю имён; проверка «код не из редакции формы» — ядро по своей копии перечня общего кода + - scan-gateway: `_choose_edition` → выбор редакции перечня кодов в общем коде + - tabular-parser: добавляет выбор редакции перечня кодов +- **3. Справочник строк** — только в ядре; в общий код сервисов не входит; копия в шлюзе удаляется + - scan-gateway: удаляет `api/rules/statement_lines.yaml` + - tabular-parser: без изменений +- **4. Уверенность кода** — от источника (ИС / 100), без срезания до 99 по справочнику; «код не в справочнике» — проверка ядра, предупреждение `info` к строке, не сервисов + - scan-gateway: убирает `min(conf, 99)` + - tabular-parser: как сейчас + +### Шаг 6 — Сборка документа + +**Вход:** `FormCandidate` (`type_hint`, `evidence`, `header`) + `TableLayout` + `StatementLine[]`. + +**Единый выход:** `StatementDocument {document_type, form_variant, locator, header, columns, lines}` без `checks`. + +- **1. Тип формы** — одно правило: большинство кодов в диапазоне ББ → `BALANCE`, ОФР → `INCOME`, прочих форм и пояснений → `DISCLOSURE`; совпало с `type_hint` или `type_hint` `null` — тип по кодам; противоречит — тип по кодам + предупреждение `error`; кодов нет — `type_hint`. Без колонки кодов: `type_hint` есть → документ без строк (`DISCLOSURE` — `done`, иначе `needs_check` с предупреждением `error`); `type_hint` нет: у `ocr` каждая таблица ИС с содержимым — потерянный документ, предупреждение `error` файла (сегодня шлюз: «нет шапки и столбца кода» → `needs_check`), у `excel` лист «прочее» — `info`. `DISCLOSURE` по `type_hint` — шаги 2–7 не выполняются; `DISCLOSURE` по кодам — шаги 2–5 выполнены, строки и колонки отбрасываются (ядро строки пояснений не хранит), проверки не считаются, документ `done`. **Вариант формы** `form_variant` — `full` | `simplified`, только по структуре кодов (ОКУД для этого не годится: 0710096 — код КНД комплекта упрощённой отчётности, на бланках стоят 0710001 / 0710002): ББ — есть 1600 и нет ни 1100, ни 1200; ОФР — есть 2400 или 2110 и нет ни 2100, ни 2200, ни 2300; шаг 7 и перечень кодов берут упрощённый набор. Образец упрощённой формы — в fixtures при первой возможности + - scan-gateway: как сейчас, через общее правило; добавляет упрощённый вариант и `error` за потерянную таблицу + - tabular-parser: добавляет сверку с кодами и упрощённый вариант +- **2. Склейка страниц** — решена в шаге 1, различия нет + - scan-gateway: без изменений + - tabular-parser: без изменений +- **3. `locator`** — одна грамматика «пары `ключ:значение` через `;`», набор ключей по источнику: `file:<имя json>;table:` / `sheet:[,]` — ключи как сейчас, golden-эталоны и §11 не меняются + - scan-gateway: без изменений + - tabular-parser: без изменений + +Шапка документа — поля `header` кандидата как есть (`text` → `*_text`, `value` → поле, `confidence` → `*_confidence` +у всех полей). + +### Шаг 7 — Самопроверки + +**Вход:** `StatementDocument`. + +**Единый выход:** `StatementChecks` у обоих: `relations_failed[]`, `soft_relations_failed[]` (каждое нарушение — с +кодами слагаемых и `column_index`, чтобы экран подсветил ячейки). Правила счёта: + +- значение кода в колонке — сумма по всем строкам `ok` с этим кодом (повтор кода — законный случай, шаг 3 п. 3); строки + `detail`, `unreadable`, `missing` в соотношения не входят; `duplicate_line_codes` уходит; нарушение подсвечивает все + строки кода; +- соотношение считается **посчитанным**, если непуста цель или хотя бы один член; пропускается, только когда пусто + всё (ни строки `ok` с кодом цели, ни одного непустого члена). Пустая цель при непустых членах — жёсткое нарушение + («БАЛАНС» без кода, ушедший в `detail`, делает 1600 невидимым, и это должно подсветить родителя); непустая цель при + пустых членах — тоже нарушение (лист только с активом: «1600 = 1700» при отсутствующем 1700 — `needs_check`, как + сегодня у трёх файлов манифеста парсера). Сегодня оба сервиса и ядро соотношение с отсутствующей целью пропускают + — поведение меняется; посчитанное соотношение из всех `null` ничего не подтверждает (то же правило, что для + «родитель = Σ подстрок» по AURORA-1527); +- «сошлось» / «не сошлось»: жёсткое соотношение не сошлось, если расхождение больше допуска ±1 **и** больше + округления 0,001 % — только тогда оно в `relations_failed`; расхождение в пределах округления и мягкие соотношения — + в `soft_relations_failed`, это `info`, на статус и подсветку не влияют (OQ-138 сохраняется); +- соотношение с членами по модулю (`abs_terms`: 2120, 2210, 2220, 2330, 2350, 2410) сходится при любом знаке такого + члена и потому **подтверждает только величину, не знак** — см. «Подтверждение проверкой»; +- набор соотношений выбирается по `form_variant`: полный или упрощённый (нет подытогов 1100 / 1200 / 1300 у ББ, + 2100 / 2200 / 2300 у ОФР, свои равенства), оба — в одном словаре `statement_checks.yaml`; +- родитель пятизначной подстроки — ближайшая четырёхзначная строка выше с теми же первыми тремя цифрами, иначе код + из первых четырёх цифр (`subline_parents`, §12 п. 7: 12310 под 1230). + +Проверки по словарю имён (код не в словаре) и по перечню кодов (код не из редакции формы по эффективной дате) — в +ядре, как предупреждения `info` к строке; сервисы словаря имён не знают (Р-3, Р-12). + +- **1. Проверки только у парсера; словарь §7 в двух копиях; ядро результат не читает** — проверки — общий код с одним словарём `statement_checks.yaml` у обоих сервисов; для OCR нарушение §7 — главный признак неверно прочитанной цифры; ядро читает `checks` из результата, хранит их (`document_issue`, см. «Что уходит из ядра») и пересчитывает тем же кодом только после правок аналитика (PATCH) + - scan-gateway: добавляет шаг + - tabular-parser: `run_statement_checks` → общий код + +### Шаг 8 — Статус и предупреждения + +**Вход:** `StatementDocument` + `StatementChecks` + предупреждения шагов 1–7. + +**Единый выход:** у каждого документа `status` (`done` | `needs_check`), `warnings[]`, `needs_review` у каждого +значения, каждой строки и каждого поля шапки; у файла `metrics.warnings[]`; статус конверта — агрегат. +Предупреждение — структура `{code, severity: error | info, message, line_refs?: [], column_index?, field?}`, а не +строка; `line_refs` — список, потому что нарушение подсвечивает все строки кода. + +- **1. Критерий `done`** — одна функция общего кода с входами «раскладка, шапка, статусы строк, `needs_review`, нарушения, предупреждения, подтверждения и акцепт аналитика»: раскладка найдена ∧ (организация или ИНН) ∧ период определён (дата отчёта **или** самая поздняя абсолютная дата колонок — `statement_period` ядра; у ИС поле `date` часто мусор: у aep_6 и «БО 1 кв 2025» там название организации) ∧ все строки `ok` или `detail` ∧ нет значений, строк и полей шапки с `needs_review` (см. «Подтверждение проверкой» ниже) ∧ нет жёстких нарушений §7 вне допуска и округления ∧ нет предупреждений уровня `error`. `DISCLOSURE` — `done` без условий о раскладке, строках и проверках (как сейчас). Excel выполняет условия уверенности автоматически; структурная проверка кодов к Excel не применяется (Р-12). Квартал периода, статус конверта, сверка ИНН и наименований — знание ядра, в `done` не входят + - scan-gateway: «ничего не выброшено и уверенность 100» → общий критерий + - tabular-parser: `_is_ok` → общий критерий; добавляются строки и значения +- **2. Влияние предупреждений** — по уровню: `error` → `needs_check`, `info` — не влияет; у обоих одинаково. Таблица уровней, полная версия — в спеке. `error`: ошибочный json набора при успешном соседе (уровень файла; решение № 28 среза 3a), `BACKEND_PARSING_ERROR` таблицы, таблица ИС с содержимым без колонки кодов и без типа (уровень файла), сегмент с содержимым без колонки кодов (в том числе потерянный лист-продолжение), лист с тремя и более кодами и другим числом колонок, не присоединившийся к форме, колонки сегмента не совпали, колонка без даты, дата колонки неуверенна, заголовка нет ни у одного сегмента, тип по кодам противоречит заголовку, тип маркера продолжения противоречит форме, ни организации, ни ИНН в шапке, период не определён (нет ни даты отчёта, ни дат колонок), дата отчёта неуверенна и не совпала с датами колонок, xlsb без форматов при «тыс. руб.» и дробных. `info`: ИНН не найден или неуверен при найденной организации (сверку с ЮЛ делает ядро — `MISMATCH`; НПЗ ОФР без ИНН остаётся `done`), организация не найдена или неуверенна при найденном ИНН, дата отчёта не разобрана при наличии дат колонок, единицы не найдены (аналитик правит в шапке), формат файла не совпал с `file_type` (файл прочитан верно), подпись из словаря исключений пропущена, дробные при «тыс. руб.» (кроме xlsb без форматов), xlsb без числовых форматов само по себе (tp#42, документ остаётся `done`), лист Excel «прочее» без `type_hint` и кодов, разлеплена склеенная строка, код не в словаре имён или не из редакции (ядро). Принцип: `error` — только то, что меняет данные или их полноту + - scan-gateway: «любое → `needs_check`» уходит + - tabular-parser: предупреждения начинают влиять +- **3. Статус документа** — в контракте: `document.status` у каждого документа, включая ОСВ (у ОСВ `done`, когда ведомость разобрана, как сейчас; её предупреждения — `info`); конверт `done` ⇔ все документы `done` ∧ нет предупреждений файла уровня `error`; иначе `needs_check`; документов нет — `error` с кодом по источнику: `RECOGNITION_FAILED` у шлюза, `INVALID_DOCUMENT` у парсера — это различие остаётся, коды описывают разные причины. Статус конверта в условие `RECOGNIZED` ядра не входит, туда входят предупреждения `error` уровня файла (Р-19) + - scan-gateway: добавляет + - tabular-parser: внутренний `ok` → в контракт +- **4. Формат предупреждений** — структура (см. выше) вместо строк `: table:: …` / `sheet=<имя> …` у всех документов результата, включая ОСВ; место — в `locator` документа, а не в тексте + - scan-gateway: меняет + - tabular-parser: меняет, включая путь ОСВ +- **5. Привязка** — к документу, строкам (`line_refs`), ячейкам (`line_refs` + `column_index`) или полю шапки (`field`); `ref` строки уникален в документе и хранится в ядре вместе с `row_index`, поэтому экран показывает предупреждение у места и после сохранения + - scan-gateway: меняет + - tabular-parser: меняет + +**Подтверждение проверкой.** Проверка §7 — арбитр, уверенность — подсказка там, где проверки нет. По каждому +значению сервис считает `needs_review`: + +- значение **нуждается в покрытии**, если `confidence` ниже порога (Р-5: 100) или `is_recognized = false` (число + собрано из грязного текста, с заменой двойников или из разлепленной строки — Р-7, Р-11, Р-17); чистые значения + (уверенность 100 и `is_recognized = true`) в покрытии не нуждаются: правило покрытия и правило знака к ним не + применяются; +- значение **покрыто**, если в его колонке есть жёсткое соотношение с участием его кода, которое посчитано и сошлось + (в пределах допуска или округления), и `amount` самого значения не `null` (мусор, давший `null`, покрытым не + бывает); +- **знак**: для члена соотношения с `abs_terms` покрытие подтверждает только величину. Знак подтверждён, если форма + записи знака у ячейки (скобки, минус, без знака — по `text`) совпадает с конвенцией колонки — формой записи у + большинства членов `abs_terms` этой колонки с непустым `amount`, `is_recognized = true` и без правки аналитика + (правленые ячейки в голосование не входят); не совпадает или сравнивать не с чем — `needs_review = true` независимо + от соотношения. Так «(7744536}» с `}` как закрывающей скобкой даёт −7 744 536 и подтверждается, если 2210 и 2220 в + той же колонке тоже в скобках; «{38378)» при остальных в скобках — подсвечивается; +- `needs_review = true`, если значение нуждается в покрытии и не покрыто (или не подтверждён знак); +- `true` у всех ячеек жёсткого соотношения в колонке, где оно не сошлось вне допуска и округления, независимо от + уверенности и чистоты; мягкие соотношения и расхождения в пределах округления подсветки не дают; +- иначе `false`: значение подтверждено проверкой, не подсвечивается и статус не трогает; сырой текст (`text`) и + уверенность остаются в подсказке ячейки. + +Экран подсвечивает ячейки только по `needs_review`. Ядро хранит `needs_review` и `text` как пришли и пересчитывает +`needs_review` вместе с `checks` общим кодом после PATCH (конвенция знака считается от сохранённого `text`), добавляя +своё: правленое значение (`is_modified`) или подтверждённое аналитиком без изменения (`is_confirmed`, хранится) — +`needs_review = false`; акцепт нарушений — жёсткие нарушения не считаются; предупреждения `error` документа +сохраняются и после правки, кроме снимаемых соответствующей правкой (таблица — в «Что уходит из ядра»). Не покрыто +проверками и остаётся на уверенности: справочные строки ОФР 25xx / 29xx, детализация без проверки родителя, +`DISCLOSURE`, колонки, где есть только мягкие соотношения. Риск — две взаимно компенсирующие ошибки в одном +соотношении; принимается (перекрёстные соотношения баланса его снижают). Закрывает AURORA-1524 без ущерба для 1519. +Сошедшееся посчитанное равенство подтверждает и догадку из грязного текста (Р-7): случайное совпадение требует второй, +компенсирующей ошибки в том же равенстве. + +**Поля шапки.** У каждого поля шапки свой `needs_review`: дата отчёта с уверенностью ниже 100 подтверждена, если +совпала с самой поздней абсолютной датой колонок, иначе `needs_review = true` и предупреждение `error` «дата отчёта +неуверенна» — иначе неверно прочитанный год давал бы неверный период и автоподтверждение; ИНН и организация с +уверенностью ниже 100 — `info`, их сверяет ядро (ИНН — `MISMATCH` с ЮЛ, организация — по алиасам); единицы и прочие +поля на статус не влияют. Сегодня шлюз требует уверенность 100 у ИНН и организации и распознанную дату — правило +становится точнее, не слабее. + +**Код строки (Р-5, Р-12, Р-16).** Уверенность кода подтверждается не §7 (перепутанные соседи 1220 и 1230 дают ту же +сумму раздела), а структурой и наименованием; правило применяется **только к неуверенным кодам** — уверенность ниже +100 или замена двойника (Р-12): у Excel и чистых OCR-кодов расхождение с перечнем даёт предупреждение `info` ядра «код +не из редакции», а не `needs_check` — иначе пятизначные подстроки 1С (11101, 23201) и нестандартные строки клиента +(1231) блокировали бы автоподтверждение. В общем коде лежит **упорядоченный перечень кодов** каждой формы по +редакциям (66н, ФСБУ 4) и вариантам (полная, упрощённая) без наименований — порядок бланка, где итоги стоят после +слагаемых (1110 … 1190, 1100; 2110, 2120, 2100), а не по возрастанию. Источник перечня — объединение кодов шаблонов +банка (словарь ядра) и кодов печатных форм 1С по редакции: 1105, 1530, 2420, 2460, 2930 и другие, которых в словаре +ядра нет. Алгоритм: четырёхзначные коды строк документа образуют последовательность; для каждой проверяемой +редакции берётся наибольшая подпоследовательность, согласованная с порядком бланка; редакция — та, где она длиннее +(при равенстве — по эффективной дате); если таких подпоследовательностей несколько, подтверждены только коды, общие +для всех; коды с уверенностью 100 участвуют в последовательности как подтверждённые якоря. Пятизначный код +подтверждён, если подтверждён его родитель (правило шага 7) и подстрока стоит после него. Равные соседние коды +допустимы, но повтор кода, у которого хотя бы одна копия неуверенна, — `needs_review` у неуверенной строки (Р-16). +Неподтверждённый неуверенный код — `needs_review` у строки (`StatementLine.needs_review`), документ — `needs_check`. +Структура не ловит подмену неуверенного кода на незапечатанного соседа того же раздела (1110 → 1120, когда 1120 на +бланке пропущен): это закрывает ядро — для строк с неуверенным кодом условие `RECOGNIZED` включает совпадение +распознанного наименования со справочным по коду (Р-16; порог сходства — в спеке), расхождение подсвечивает строку +через `document_issue`. По `fixtures/ocr` коды часто приходят с уверенностью 50 и 25, поэтому требовать 100, как +шлюз сейчас, нельзя. Перечень кодов — не словарь строк: наименований в нём нет, Р-3 не нарушается; ядро пользуется +своей копией перечня для проверки «код не из редакции», словарь ядра хранит только имена. Уверенность наименования +на статус и подсветку в сервисах не влияет: сервисы отдают текст как есть. + +### Шаг 9 — Результат + +Одинаково, как сейчас; контракт `recognition-result.schema.json` → **v2.0** (Р-9: по правилам контракта смена типа +поля — MAJOR). Что меняется: `line_code` nullable, `line_status`, `line_code_text`, `row_index`, `ref`, +`parent_ref`, `needs_review` у значения, строки и полей шапки, `values[].text`, `header.*_confidence` и `*_text` у +всех полей; `document.status` у всех документов, включая ОСВ, `document.form_variant`; `document.warnings[]` и +`metrics.warnings[]` — структуры с уровнем и привязкой у всех документов, включая ОСВ; `columns[].period_kind` и +`label_confidence`; `checks` обязателен для `BALANCE` / `INCOME` со строками у обоих конвейеров (у документа без строк +отсутствует), нарушения с кодами слагаемых, без `duplicate_line_codes`; `service.statements_version` — коммит копии +общего кода, которым посчитан результат. Выкладка: ядро поднимает `SUPPORTED_MAJOR` и на время перехода принимает +1.x и 2.0 (два маппера, ack тоже 1.x и 2.0; при приёме 1.x статус документа считается по прежнему правилу D-6 — +`job_done ∧ проверки ∧ values_ok ∧ …`, новые поля заполняются по умолчанию: `line_status = ok`, `row_index` по +порядку, `text = string_value`), сервисы переходят на 2.0 после ядра — порядок «сначала ядро» обязателен, иначе +первый же результат с `null` в `line_code` даёт файлу `ERROR`. Запрос `recognition-request` остаётся 1.x. Путь ОСВ +в парсере меняется только в части структуры предупреждений и `document.status`. + +### Что уходит из ядра и что в нём меняется + +- `build_statement`: повторная дедупликация колонок, кодов и значений (контракт гарантирует уникальность колонок; + повторы кодов — законные строки, хранятся все как пришли). +- **DBML v5.19 и одна миграция:** `financial_statement_line` — снять уникальность `(meta_id, line_code)`, добавить + `row_index` (у строк из источника; у шаблонных и созданных правкой — `null`), `source_ref` (частичный уникальный + индекс по `(meta_id, source_ref)` там, где не `null`), `line_status`, `line_code_text`, `parent_row_index` (родитель + `detail`), `needs_review`, `is_confirmed` (подтверждение кода аналитиком); `financial_statement_value` — + `source_text` (из `values[].text`), `needs_review` (как пришло от сервиса, пересчитывается ядром после PATCH; + пересматривает решение v5.13 «считается, не хранится»), `is_confirmed` (подтверждено без изменения, хранится вместе + с `confirmed_by / at`); `financial_statement_column.period_kind`, `label_confidence`; `financial_statement_meta.form_variant`, + `needs_review` полей шапки; таблица `document_issue` (вид `warning` | `relation` | `soft`, код, уровень, сообщение, + список `line_id`, `column_id?`, `field?`, источник `service` | `core`) — хранит предупреждения и нарушения как + пришли; при перезагрузке файла очищается; при пересчёте после PATCH записи `relation` / `soft` заменяются целиком + независимо от источника, записи `warning` источника `service` сохраняются, кроме снимаемых правкой; + `recognition_job.statements_version`; `document` — снять уникальность `(stored_file_id, document_type, + osv_account_code)` только для `BALANCE` / `INCOME` (частичный индекс: ОСВ по счёту и `DISCLOSURE` / `OTHER` остаются + «один на тип на файл», §9.5 п. 2) и добавить порядковый номер формы в файле (Р-10); в ту же миграцию — `crm_id` + ГСК / ГСЗ (AURORA-1619, arm-core#112). Заполнение при миграции: `row_index` существующих строк — по порядку кода, + `line_status = ok`, `needs_review` значений — по прежнему `values_ok`, `source_text = string_value`. +- Повторы кодов (Р-4): для проверок §7, подтверждения и всего, что идёт после (КФО, выгрузки, сверки), документ + читается через **одну функцию «агрегат формы по коду»** — сумма значений строк `ok` по колонке, список `line_id`, + справочное имя — чистой функцией, без хранения (DECISION-5). На неё переводятся все точки, где сегодня «одна + строка на код»: `_line_amount` (межформенная проверка 1370 / 2400), `statement_amounts`, `subline_parents` / + `parent_code`, `formLayout` (дети под родителем), `_resolve_cells`, `metrics.fields_*`. +- `statement_status` (D-6) при приёме 2.0: `RECOGNIZED` ⇔ `document.status = done` ∧ нет предупреждений `error` + уровня файла (потерянный json или таблица ИС, решение № 28 среза 3a; `needs_check` соседнего документа само по себе + не блокирует — Р-19) ∧ ИНН ≠ `MISMATCH` ∧ период — конец квартала (`period_known`: иначе документ `RECOGNIZED`, но + не подтверждаемый, 409 `PERIOD_REQUIRED`) ∧ у строк с неуверенным кодом распознанное наименование совпало со + справочным (Р-16) ∧ в файле нет второй формы того же типа и периода (Р-18: при двух и более — ни одна не + автоподтверждается). Остальные признаки D-6 не пересчитываются. +- §7 при загрузке: `checks`, `needs_review` и предупреждения берутся из результата и хранятся (`document_issue`); + ядро добавляет только `info` по словарю имён и перечню кодов и свою сверку наименований для неуверенных кодов. + Пересчёт общим кодом — только после PATCH, с правилами `is_modified` или `is_confirmed` ⇒ `needs_review = false` у + значения; `line_code_modified` или `is_confirmed` ⇒ `needs_review` строки = false; акцепт ⇒ жёсткие нарушения + сняты. Версия общего кода из результата (`statements_version`) пишется в `recognition_job`; при пересчёте ядро + сравнивает её со своей копией, расхождение — предупреждение `info` к документу (на окне выкладки «сначала ядро» + расхождение штатно). +- **Какая правка какое `error` снимает:** PATCH `header.report_date` — «период не определён», «дата отчёта не + разобрана», «дата отчёта неуверенна»; PATCH `header.inn` или `organization_name` — «ни организации, ни ИНН»; PATCH + `columns[].period_date` — «колонка без даты», «дата колонки неуверенна»; назначение кода или отметка `detail` — + нарушения, пересчитываемые §7. Остальные `error` сервиса (колонки сегмента не совпали, заголовка нет, тип + противоречит, потерянный json, лист или таблица, xlsb с масштабом) снимаются только перезагрузкой файла. +- **PATCH `…/statement`** (Р-13): помимо `header` и `values` принимает `values[].confirmed` (подтвердить ячейку без + изменения значения: `is_confirmed`, `needs_review = false`, `is_modified` не ставится, аудит), `columns: + [{column_id, period_date}]` (дата колонке без подписи или с неуверенной подписью; пересчитывает `is_current`; + `report_date: null` обнуляет `period_end` относительных колонок) и `lines`: «назначить код» (`line_id`, `line_code`), + «отметить расшифровкой» (`line_id`, `line_status = detail`), «подтвердить код» (`line_id`, `confirmed`). Переходы: + `unreadable` / `missing` / `detail` → `ok` назначением кода; `unreadable` / `missing` / `ok` → `detail` отметкой; + `ok` → `ok` с другим кодом (смена кода, прежний в `line_code_original_value`); `ok` неуверенный → подтверждён. + Правила: код 4–5 цифр, может совпадать с существующим (Р-4); незнакомый словарю — `info`; назначенный или + подтверждённый код снимает `needs_review` строки, `line_code_confidence` становится 100, `line_code_modified`, + `line_code_original_value`; у `detail` родитель пересчитывается как ближайшая строка `ok` выше по `row_index`; + шаблонная строка с таким кодом, если она уже создана правкой аналитика с суммами, — 409 `LINE_EXISTS` с подсказкой + (аналитик сначала очищает её суммы или выбирает другой код), без сумм — удаляется, её место занимает настоящая; + пара «`line_status = ok` без кода» — 422; удаления строк нет (первичка не мутируется, `detail` — способ вывести + строку из сумм). Строки, созданные правкой: `row_index = null`, `source_ref = null`, `line_status = ok`. Аудит + `RECOGNIZED_DATA_EDIT` с `before / after` по коду, статусу, родителю и подтверждению. Один PATCH — один пересчёт и + одна запись. +- **Подтверждение документа** (Р-13, Р-18): порядок проверок 409 по §9.5 п. 4: `PERIOD_REQUIRED` → `LINES_UNRESOLVED` + (есть строки `unreadable` / `missing` с непустым `amount`; строки без сумм не мешают) → `DUPLICATE_FORM` (в файле уже + подтверждена другая форма того же типа и периода; 409 со ссылкой на неё; между файлами работает автозамена §9.5 п. 3) + → жёсткие нарушения без акцепта. Автоподтверждения `LINES_UNRESOLVED` не касается (`done` таких строк не + допускает). `detail` в агрегат, КФО и снимок ЕХ не попадают — цена Р-8. +- Правка даты отчёта (PATCH шапки): для колонок с `period_kind = relative:<вид>` ядро досчитывает `period_end` общим + кодом от новой даты. +- Словарь §7: единственный экземпляр в общем коде (Р-1), копия ядра удаляется. Словарь имён строк (код → наименование + по редакциям) остаётся только в ядре, копия шлюза удаляется; порядок и состав редакций ядро берёт из своей копии + перечня общего кода. +- Достройка шаблонных строк: по словарю ядра; код, у которого есть хотя бы одна строка `ok` из файла, шаблоном не + достраивается. +- **Экран** (Р-14): раскладка остаётся «как на бумаге» — разделы, внутри строки `ok` по возрастанию кода, несколько + строк одного кода в порядке файла, шаблонные строки на своих местах, пятизначные подстроки под родителем. Строка + `unreadable` / `missing` ставится под ближайшей предыдущей по `row_index` строкой, которая разместилась по коду, а + без такой — в начало первого раздела; подсвечена целиком, в ячейке кода серым сырой текст с подсказкой. Строки + `detail` скрыты под переключателем «Показать расшифровки (N)», как пустые строки детализации, показываются под + родителем с отступом. Подсветка ячеек только по `needs_review` (уверенность, `is_recognized` и нарушения §7 уже + сведены сервисом), в подсказке — `text` и уверенность; поля шапки с `needs_review` подсвечены; предупреждения и + нарушения — из `document_issue`, с привязкой к месту. В режиме «Редактировать»: ячейка кода у `unreadable` / + `missing` — поле ввода с подсказкой из справочника (код → справочное имя), меню строки «Это расшифровка»; у `detail` + — «Назначить код»; у обычной строки — «Сделать расшифровкой», смена кода и «Подтвердить код» у неуверенного; + подтверждение ячейки без правки; дата у колонки без подписи. Кнопка «Подтвердить данные» при неразобранных + строках с суммами отключена с подсказкой «Разберите строки без кода: N» и переходом к первой; сервер отвечает тем + же 409. +- Открытый вопрос: склейка двух половинок формы из одного файла не предоставляется, файл перезагружают — в + `07-open-questions.md` (Р-15). Вопрос о сопоставлении файлового наименования со справочным закрыт Р-16: для + неуверенных кодов это условие `RECOGNIZED`, для остальных — только показ справочного имени. + +### Решения владельца (Р-1…Р-19 — 17.10.2026, Р-20…Р-43 — 18.10.2026) + +- **Р-1** (шаг 1, п. 2) — **копия по скрипту.** Платформа не собирает Python-пакеты и не кладёт их в свой Nexus, поэтому + общий код — dataclass-ы шага 1 (сериализуемые в json), шаги 2–8, словари §7, периодов, заголовков форм, перечни и + диапазоны кодов (без словаря имён) со своим загрузчиком — живёт в отдельном репозитории `arm-statements` со своими + тестами и оформлен как пакет `arm_statements/` с `pyproject`. Границы пакета: пакет владеет своими типами входа и + выхода, хост конвертирует выход в свою модель контракта (у ядра на переходе их две); golden-тесты пакета — внутри + пакета на синтетических и обезличенных данных, тесты на реальных фикстурах остаются в scan-gateway и tabular-parser + и соседний репозиторий не читают. В tabular-parser, scan-gateway и arm-core пакет лежит копией вместе со своими + тестами и конфигом линтеров (конфиг пакета подключается к `pytest` и `ruff` хоста как include, чтобы правила не + конфликтовали), импортируется по имени пакета `arm_statements` (путь добавляется в `pyproject`), поэтому замена + копии на зависимость не трогает импорты; маркер коммита источника попадает в результат как + `service.statements_version`; раскладывает и проверяет свежесть скрипт `sync-statements` в docs по образцу + `sync-web.sh` (`--check`, код 20 при прямых правках), вызывается из `make check-full` перед PR локально — **не** из + `make check` и не из пайплайна: тест, читающий соседний репозиторий, уже ломал main (arm-core #33, пайплайн + платформы вне репозиториев). Прямые правки копии запрещены. Срочное исправление общего кода: PR в `arm-statements` + → `sync-statements` → PR в каждом потребителе, порядок выкладки «сначала ядро». +- **Р-2** (шаг 4, п. 5) — отсутствующая позиция под колонкой — чистый `null` без собственной подсветки. Основание: по + fixtures ИС отдаёт ячейку на каждую позицию (пустая — уверенность 100, непрочитанная — 0, и её ловит общее правило + `needs_review`); отсутствие позиции бывает только при несовпадении колонок сегмента и покрыто предупреждением + шага 2 п. 2. +- **Р-3** (шаг 5, п. 1) — сервисы отдают распознанный текст наименования; соответствие «код → наименование» — только в + ядре, при необходимости (= решение № 2 по спринту 2, AURORA-1406 / 1522; договорённость ДБД «из справочника» + выполняется на экране). +- **Р-4** (шаг 3, п. 3) — повторы кодов законны; все строки сохраняются и показываются, для проверок и всех операций + после подтверждения или автоподтверждения — суммой по коду. Следствие: снять уникальность `(meta_id, line_code)` в + DBML; все чтения «одна строка на код» в ядре переводятся на агрегат. +- **Р-5** (шаг 8, «Подтверждение проверкой») — порог для значений — 100 (у ИС уверенность дискретная: 100, ~80, 71, + 67, 60, 50, 25, 0, промежутка 90–99 нет; в группе 70–89 есть настоящие ошибки, например «11 30» с уверенностью 80); + ложных подсветок становится меньше за счёт подтверждения проверкой §7, а не порога. Код строки подтверждается + структурой — упорядоченным перечнем кодов формы по редакции, не уверенностью; область применения — Р-12, + дополнение — Р-16 (= решение № 7 по спринту 2, AURORA-1519 / 1524). +- **Р-6** (шаг 2, п. 6) — числовая колонка с нераспознанной подписью остаётся в документе без даты с предупреждением + `error` (`needs_check`, подтверждение руками); пропуск отвергнут как тихая потеря периода. Относительные подписи + бланка 66н («На отчётную дату», «На 31 декабря предыдущего года», …) добавляются в словарь периодов и считаются от + эффективной даты документа; после правки даты отчёта ядро досчитывает их; дату колонке можно поставить руками. +- **Р-7** (шаг 8, «Подтверждение проверкой») — **да:** сошедшееся жёсткое соотношение §7 снимает подсветку и с + `is_recognized = false`; такой документ может получить `done` и автоподтвердиться, сырой текст остаётся в подсказке + ячейки. Оговорки по ревью: соотношение должно быть посчитанным, а не «0 = 0»; для членов по модулю знак + подтверждается конвенцией колонки, а не соотношением; правило знака — только для сомнительных ячеек. Варианты + «всегда подсвечивать» и «снимать подсветку, но не автоподтверждать» отвергнуты: первый возвращает ложные подсветки + AURORA-1524, второй даёт статус «требует проверки» без подсветок. +- **Р-8** (шаг 3) — расшифровки без кода («в том числе …») отдаются сервисами со статусом `detail` и хранятся ядром; в + проверки, агрегаты и `done` не входят; экран по умолчанию их не показывает (переключатель, как у пустых строк + детализации). Отменяет OQ-36 / OQ-141. +- **Р-9** (шаг 9) — контракт **v2.0**, а не аддитивный 1.3; ядро на время перехода принимает 1.x и 2.0; порядок + выкладки — сначала ядро. +- **Р-10** (ядро) — снять уникальность `document` по `(stored_file_id, document_type, osv_account_code)` для форм + ББ / ОФР, вторая форма того же типа в файле хранится; в ту же миграцию DBML v5.19 — `crm_id` ГСК / ГСЗ из AURORA-1619 + (arm-core#112). +- **Р-11** (принцип 5) — буквы-двойники заменяются и в суммах, и в кодах; результат — `is_recognized = false` у суммы и + неуверенный код, дальше их проверяют контрольные соотношения §7 и структура, и если всё сошлось, подсвечивать не + нужно (Р-7 с оговорками). Отменяет §11.1 п. 1 «только даты» и решение № 22 среза 3a. ИНН двойники не трогают. + Данные: в четырёх json ИС двойников в суммах и кодах нет вовсе — правило на вырост, не под текущие сканы. +- **Р-12** (шаг 8, «Код строки») — структурное подтверждение кода применяется только к неуверенным кодам + (уверенность ниже 100 или замена двойника); у Excel и чистых OCR-кодов расхождение с перечнем даёт `info` от ядра, + не `needs_check`. +- **Р-13** (подтверждение документа) — подтверждение документа блокируется (409 `LINES_UNRESOLVED`), пока есть строки + `unreadable` / `missing` с суммами; аналитик назначает код или отмечает строку расшифровкой в режиме правки, через + `PATCH …/statement lines` (детали — в «Что уходит из ядра»). +- **Р-14** (экран) — раскладка по коду «как на бумаге» остаётся (просьба владельца 09.10.2026); строки со статусами + ставятся под ближайшей предыдущей размещённой строкой по `row_index`, расшифровки под родителем и скрыты по + умолчанию; переключатель «как в файле» — не в этой работе. +- **Р-15** (ядро) — вторая форма того же типа и периода у одного ЮЛ из того же файла хранится; склейка половинок формы + из одного файла не предоставляется (файл перезагружают), вопрос — в `07-open-questions.md`. От ИС Сканирование + такой случай не ожидается (владелец), основной источник — Excel с листом-продолжением другого типа или двойной + печатью. Уточнено Р-18. +- **Р-16** (шаг 8, «Код строки»; ядро) — защита от подмены неуверенного кода на соседний: повтор кода, у которого хотя + бы одна копия неуверенна, — `needs_review` у этой строки (сервис); для строк с неуверенным кодом условие + `RECOGNIZED` в ядре включает совпадение распознанного наименования со справочным по коду, расхождение подсвечивает + строку. Закрывает открытый вопрос о сопоставлении наименований: для неуверенных кодов — условие автоподтверждения, + для остальных — только показ справочного имени. +- **Р-17** (шаг 3, п. 2) — разлепленные строки OQ-156 не держат документ в `needs_check` безусловно (решение 08.10.2026 + заменяется): их значения нуждаются в покрытии, как грязные, и при сошедшихся соотношениях §7 не подсвечиваются; + само разлепление — предупреждение `info`. +- **Р-18** (ядро) — при двух и более формах одного типа и периода в одном файле ни одна не автоподтверждается; + подтверждение второй при подтверждённой первой — 409 `DUPLICATE_FORM` только внутри файла (между файлами работает + автозамена §9.5 п. 3). +- **Р-19** (ядро) — `RECOGNIZED` не зависит от статуса конверта: достаточно `document.status = done` и отсутствия + предупреждений `error` уровня файла (потерянный json или таблица ИС блокируют, `needs_check` соседнего документа + из-за одной ячейки — нет). + +- **Р-20** (18.10.2026; ревью спеки K-4, повторы кодов) — законные повторы кодов (в реальных ББ 2025 — 1220 «НДС, + начисленный по отгрузке» и 1520 «Обязательство по аренде») допускаются в упорядоченном перечне кодов: структура + подтверждает обе копии. Правило Р-16 «повтор с неуверенной копией — `needs_review`» применяется только к повтору, + которого нет в перечне. Словарь имён ядра — список имён (синонимов) на код; загрузчик перестаёт запрещать повтор кода в + этом смысле (сверка наименования — с любым из имён). В спеке: перечень `contracts/statement-code-orders.yaml` + (`repeats`: у `fsbu4` — 1220, 1520, 2410 печатной формы 1С; у `66n` — раздел III НКО, см. S-28), §11.9, §7 «Полный + состав формы», спеки `statement-normalization` и `arm-core`. +- **Р-21** (18.10.2026; ревью спеки K-4, мягкие соотношения ОФР) — неуверенное значение подтверждается и мягким + соотношением, если оно сошлось точно (расхождение 0 или в пределах допуска ±1; не «в пределах округления»). Применяется + к 2400, 2410, 2411, 2412, 2460 и прочим строкам, у которых есть только мягкие соотношения. Знак для `abs_terms` — по + прежнему правилу конвенции колонки. В спеке: §7, §11.9, требование «Шаг 8 — `needs_review` значения». +- **Р-22** (18.10.2026; решения писателя S-1…S-25) — приняты все, с оговорками ревьюера из таблицы S-n в + `review-2026-10-18-spec.md`: S-4 — синонимы на код и исключение «итого по разделу» из правила вхождения; S-5 — в + упрощённом ОФР `2400` мягкое, как в полном, ББ — жёсткие; S-7 — OQ-148 закрывается этим решением; S-10 / S-22 — одна + формулировка; S-13 — двойники внутри детектора нумерации и допуск одного пропуска; S-15 — экранирование имени листа в + Excel-`ref`; S-17 — доопределить по K-23; S-19 — `codes` и `column_index` у `document_issue`; S-21 — по K-2 / K-21. + Явно утверждены владельцем S-4, S-5, S-7, S-10, S-12, S-13, S-14, S-20, S-22, S-25 (отмечены ниже). + +- **Р-23** (18.10.2026; повторное ревью спеки L-1) — значения строк `detail` в `needs_review` и в статус документа не + входят: у них `needs_review = false`, функция статуса их не учитывает; подсказка на экране по `text` и `confidence` + остаётся. Следствие: на реальных сканах РБ и Счастливочной за 1 кв. 2025 формы могут стать `done` (по прогону + ревьюера держали их только ячейки расшифровок). В спеке: `statement-normalization` («Шаг 8 — `needs_review` значения», + функция статуса, сценарий на РБ), `arm-core` (пересчёт после правки, согласованность), `arm-web`, §11.9, proposal; S-51 + пересмотрено. +- **Р-24** (18.10.2026; L-13 и упрощение S-36) — перед выкладкой ядра база очищается от форм ББ/ОФР (документы + `BALANCE` / `INCOME`, их `financial_statement_*`, комментарии, привязки к версиям «в работе»; файлы, где кроме форм + ничего нет, — целиком с заданиями; ОСВ, консолидации, справочники не трогаются; детали и отказы — S-73). Поэтому + миграция v5.19 только добавляет колонки, индексы и значения по умолчанию; §7 для существующих форм не пересчитывается, + импорта копии `arm_statements` в ревизии Alembic нет. Миграция обязана проходить и на непустой базе (колонки по + умолчанию, `file_ordinal` по порядку, `document_issue` пуст), но корректность нарушений у старых форм не + гарантируется — сказано явно. Для приёма 1.x после выкладки (окно перехода) §7 по-прежнему считается ядром копией + пакета при приёме (остаётся из S-36). В спеке: proposal (порядок выкладки), `arm-core` («Миграция DBML v5.19», + «Очистка форм перед выкладкой», «Формы результата 1.x — §7 при приёме»), §7 «Ядро», §9.5 п. 2, DBML v5.19 (шапка). +- **Р-25** (18.10.2026) — утверждены владельцем решения писателя S-26, S-34, S-36 (в редакции Р-24), S-37, S-41, S-47 + (подтверждение без правки не сбрасывает акцепт), S-48 (в редакции L-12: ядро вызывает общую функцию статуса). Отмечены + ниже. +- **Р-26** (18.10.2026; OQ-168) — коды без образца в fixtures (2421 / 2430 / 2450 в 66н, 1120 и другие в ФСБУ 4) в + перечень кодов не входят до появления образцов; неуверенный такой код на скане — `needs_review` строки. Временное + правило, принятое владельцем; OQ-168 остаётся открытым до образцов. +- **Р-27** (18.10.2026; S-73, открытый вопрос доработки) — смешанные файлы «ОСВ + формы» при очистке перед выкладкой: + удалять целиком (`--include-mixed`) или только формы — владелец решает по отчёту `purge_statements.py --dry-run` в + момент очистки; до этого скрипт по умолчанию удаляет только формы. +- **Р-28** (18.10.2026; S-55) — принято: при приёме 2.0 ОСВ получает `RECOGNIZED` по своему `osvDocument.status` и + отсутствию предупреждений `error` уровня файла, независимо от статуса соседней формы в той же книге (как Р-19 для + форм). +- **Р-29** (18.10.2026; S-67) — расхождение «шаблонные строки экрана по редакции по дате, проверки ядра — по редакции + по структуре» (бланк 66н, сданный в 2025 году) в этой работе не сводится; отдельная задача после реализации. +- **Р-30** (18.10.2026; S-59) — принято: вариант формы документа, принятого по контракту 1.x, определяется по + структуре кодов (как у 2.0), а не остаётся пустым. + +Решения владельца по открытым вопросам планов реализации (18.10.2026, `plans/`): + +- **Р-31** (план 4 В-2, план 5 Q-3) — если на прогоне реальных файлов (РБ, Счастливочная, НПЗ, Межениновская) форма + получает `needs_check` по правилу, которое спека считает законным, исполнитель останавливается и поднимает вопрос + владельцу по конкретному случаю; адаптер под файл не подгоняется. +- **Р-32** (план 5 Q-1) — успешный json ИС без таблиц даёт `JSON_FAILED`, а не молчаливый пропуск. +- **Р-33** (план 5 Q-2) — если все json набора ошибочны, конверт `error` перечисляет `JSON_FAILED` каждого json; код + ошибки файла — от первого. +- **Р-34** (план 3 O-2) — подтверждение кода строки уверенность кода не меняет: ставится флаг `is_confirmed`, + `line_code_confidence` остаётся как пришло, `line_code_modified` не ставится; снятие подтверждения возвращает + `needs_review` по прежнему правилу. Спека arm-core приводится к этому. +- **Р-35** (план 3 O-5, P3-12) — очистка форм перед выкладкой отказывает только при привязке формы к версии + консолидации в статусе «Верифицирована» или дальше; версии в работе, на согласовании, отклонённые и отменённые не + мешают. На стенде 18.10.2026 версий дальше «в работе» нет. +- **Р-36** (план 0 O-1) — в окне перехода (до перехода сервисов на 2.0) новые примеры контракта верхнего уровня в docs + не добавляются. +- **Р-37** (план 6 В-1) — экран принимается по подстановке ответа 2.0 из фикстуры и регрессии на 1.x; прогон на aep_3 — + follow-up после перехода scan-gateway на 2.0. +- **Р-38** (план 6 В-3) — подтверждённая ячейка помечается серой галочкой. +- **Р-39** (план 4 В-1) — коды предупреждений ОСВ — по источнику (`OSV_*`), без дробления. +- **Р-40** (план 3 O-3) — предупреждения `CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` пишутся только для + четырёхзначных кодов. +- **Р-41** (план 3 O-6) — смена `crm_id` ГСК / ГСЗ пишется в аудит как изменение группы. +- **Р-42** (план 2 O-3) — `sync-statements --check` добавляется в сборку снимка для контура + (`prepare-contour-snapshot.sh`), по аналогии с `sync-web`. +- **Р-43** (план 2 O-4) — репозиторий `arm-statements` вносится в таблицу workspace (`CLAUDE.md` workspace и + `context/00-workspace-layout.md`) в рамках плана 1. + +## Decisions made without discussion + +Решения агента 17.10.2026, принятые при оформлении целевой картины без отдельного обсуждения с владельцем; все +обратимы правкой спеки или словаря. + +1. **Уровни предупреждений** (шаг 8, п. 2): `error` — только то, что меняет данные или их полноту; полный список — в + шаге 8 п. 2, таблица «какая правка какое `error` снимает» — в «Что уходит из ядра». Цена ошибки: лишний или + пропущенный `needs_check`, правится таблицей уровней в спеке. +2. **`DISCLOSURE` всегда `done`** без условий о раскладке, строках и проверках; по `type_hint` шаги 2–7 не + выполняются, по кодам прочих форм строки отбрасываются — сохранено текущее поведение парсера. Цена: пояснения и + формы 3, 4, 6 не проверяются; так и сейчас. +3. **Столбец кодов** — подпись «Код» принимается при доле кодов под ней ≥ 0,6 или кодах ≥ 3, при нескольких + кандидатах — больше кодов; запасной подсчёт «кодов ≥ 3 и доля ≥ 0,6» взят у парсера; сегмент не первый с кодами + < 3 — ослабленный поиск по диапазону столбца первого сегмента (как `relaxed` парсера). Цена: таблица без подписи и + с одним-двумя кодами в первом сегменте → документ без строк с `needs_check`. +4. **Диапазоны кодов** — явные отрезки по формам (шаг 2), объединение для поиска столбца, «диапазон столбца» для + разлепления и продолжений; границы форм 3, 4, 6 уточняются в спеке. Цена: код вне отрезка не распознаётся как код; + правится словарём. +5. **Начальный словарь исключений подписей** («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») — по тестам + парсера; пополняется словарём, не кодом. +6. **Грамматика `locator`** — пары `ключ:значение` через `;`, ключи по источнику, как сейчас. Цена: нулевая. +7. **Грамматика `ref`** — OCR `row:[#]`, Excel `<лист>!`; `ref` строки уникален в документе. +8. **`value`, который не число** (дата, логическое) в колонке сумм — `amount = null`, `string_value` = текст, + `is_recognized = false`. Цена: минимальная. +9. **Предел и квантование суммы** — 16 целых цифр, 2 знака, half-up — только для числового `value` Excel; текст OCR с + тремя и более знаками после разделителя остаётся `null` + `string_value` (решение № 30 среза 3a); «догадка из цифр» + длиннее 16 — `null`. +10. **`parse_amount` знает источник**: запятая и точка у Excel — десятичные, у OCR — тысячи при группах из трёх цифр. + Единственное место, где шаги 2–8 смотрят на `source`. Разбор ОСВ не затрагивается. +11. **`checks` отсутствует у документа без строк**, обязателен у `BALANCE` / `INCOME` со строками. +12. **Правило `detail`**: строка без кода с содержимым под строкой с кодом (родитель продолжается через границу + сегмента); ячейка кода без единой цифры — пустая. Цена: строка формы с потерянным кодом под другой строкой + скрывается до нарушения §7 у родителя (цель отсутствует — нарушение); лечится назначением кода (Р-13). +13. **Посчитанное соотношение** — непуста цель или хотя бы один член; пустая цель при непустых членах и непустая + цель при пустых членах — нарушение; сегодня соотношение с отсутствующей целью пропускается у обоих сервисов и + ядра. Цена: формы без подытогов — только с упрощённым набором соотношений, иначе `needs_check`, как и сегодня. +14. **Упрощённая форма** определяется только по структуре кодов (ББ: есть 1600, нет 1100 и 1200; ОФР: есть 2400 или + 2110, нет 2100, 2200, 2300) и получает свой набор соотношений и перечень кодов; ОКУД 0710096 признаком не служит. + Цена: ошибка определения → не те соотношения → `needs_check`; образца упрощённой формы в fixtures пока нет. +15. **Конвенция знака** для членов `abs_terms`: форма записи у большинства членов `abs_terms` колонки с непустым + `amount`, `is_recognized = true` и без правки; проверяется только у ячеек, нуждающихся в покрытии. Цена: ОФР, где + сомнительные расходы записаны не так, как остальные, подсвечивает их; это и нужно. +16. **Подтверждение даты отчёта структурой**: неуверенная дата отчёта подтверждается совпадением с самой поздней + датой колонок, неуверенная подпись колонки — попаданием в ожидаемые даты; иначе `error`. Цена: скан с верной, но + неуверенной датой без колонок с датами — `needs_check`, снимается правкой даты. +17. **Хранить `needs_review`, `text`, подтверждения, нарушения и предупреждения** в ядре (`financial_statement_value`, + `financial_statement_line`, `financial_statement_meta`, `document_issue`) вместо пересчёта на каждом GET — + пересматривает решение v5.13 «считается, не хранится». Цена: ещё одна таблица и миграция; взамен ядро при чтении + ничего не считает, а подтверждения аналитика переживают пересчёт. +18. **Эффективная дата документа** = дата отчёта, иначе самая поздняя абсолютная дата колонок — та же формула, что + `statement_period` ядра; от неё считаются относительные подписи и выбирается редакция перечня кодов. +19. **`document_issue`**: очищается при перезагрузке файла, записи `relation` / `soft` заменяются целиком при + пересчёте, `line_id` — список; `error` сервиса снимаются только правкой из таблицы соответствия. +20. **Границы пакета `arm_statements`**: свои типы входа и выхода, конвертация в модель контракта на стороне хоста, + golden внутри пакета на синтетике, тесты на реальных фикстурах — только в сервисах; расхождение версий копий — + `info`. +21. **Алгоритм структурного подтверждения кода**: наибольшая подпоследовательность по порядку бланка, редакция с + длиннейшей, пересечение при неоднозначности, уверенные коды — якоря, пятизначные — через родителя. Цена: при + двух переставленных неуверенных соседях оба `needs_review`; это и нужно. +22. **Слияние шаблонной строки с назначенным кодом**: шаблонная строка с суммами аналитика — 409 `LINE_EXISTS`, без + сумм — заменяется настоящей. Цена: лишний шаг у аналитика в редком случае; двойного счёта нет. +23. **Рабочие имена** `FormCandidate`, `TableLayout`, `RawLine`, `needs_review`, `line_status`, `line_code_text`, + `row_index`, `source_ref`, `parent_ref`, `period_kind`, `form_variant`, `document_issue`, `statements_version`, + `is_confirmed`, `LINES_UNRESOLVED`, `DUPLICATE_FORM`, `arm-statements`, `sync-statements` — по образцу существующих + (`sync-web.sh`); меняются при написании спеки без последствий. + +### Решения при написании спеки (18.10.2026) + +Решения агента при оформлении спеки, контракта v2.0, правил §11 и DBML v5.19 там, где дизайн оставлял «уточняется в +спеке» или не давал значения. Маркер `S-n` — в отчёте спеки; все обратимы правкой спеки, словаря или DBML до реализации. + +24. **S-1. Имя способности контракта** — дельта к существующей `recognition-contract`, а не новая + `recognition-result-contract`. Основание: спека с этим смыслом уже есть в `openspec/specs/`. Цена: нулевая. +25. **S-2. Конверт `recognition-result-message` тоже v2.0**: `metrics.warnings` в конверте — те же структуры, что в файле + результата; производитель пишет в конверт тот же MAJOR. Основание: «ack тоже 1.x и 2.0» (шаг 9) и одна форма + предупреждений. Цена: ещё одна схема поднимает MAJOR; ядро и так принимает оба. +26. **S-3. Диапазоны кодов** — как в дизайне, без изменений (3100–3699, 4100–4499, 5000–5999, 6100–6499). Цена: код вне + отрезка не код; правится словарём. +27. **S-4. Совпадение наименований (Р-16):** нижний регистр, `ё` → `е`, всё кроме букв и цифр — пробел; совпало при + равенстве, вхождении более короткой строки (≥ 5 символов) или `difflib.SequenceMatcher` ≥ 0,8 с любым справочным + именем кода в любой редакции; нет кода в словаре имён — не совпало. Цена: ложное несовпадение — `NEEDS_CHECK` и + ручное подтверждение; ложное совпадение маловероятно у разных статей одного раздела. **Утверждено владельцем 18.10.2026 (Р-22)** с оговоркой: у кода несколько имён (синонимы, Р-20); если + одно из имён начинается с «итого по разделу», совпадение — только равенство (S-49). +28. **S-5. Формулы упрощённого набора** по бланку приказа 66н, прил. 5 (ББ: `1600 = 1150 + 1170 + 1210 + 1230 + 1250`, + `1700 = 1300 + 1350 + 1360 + 1410 + 1450 + 1510 + 1520 + 1550`, `1600 = 1700`; ОФР: `2400 = 2110 − 2120 − 2330 + 2340 + − 2350 − 2410`), все жёсткие; `ASSUMPTION` до образца (OQ-166). Цена: неверная формула — `needs_check` упрощённых форм. **Утверждено владельцем 18.10.2026 (Р-22) с изменением:** ОФР `2400 = …` упрощённого набора — + мягкое, как `2400` полного (знак 2410, OQ-12; ревью K-11); соотношения ББ — жёсткие. +29. **S-6. Словари подписей:** кода — «код», «коды», «код строки», «код показателя»; наименования — подпись, + начинающаяся с «наименование». Цена: пополняется словарём. +30. **S-7. Прочерк** (`-`, `–`, `—`, `−` без цифр) — пустое значение, `is_recognized = true` (как пустая ячейка, Р-2); + OQ-148 — допущение. Цена: тире-мусор на месте цифры не подсвечивается сам по себе, его ловит §7 (пустой член). **Утверждено владельцем 18.10.2026 (Р-22):** OQ-148 закрыт этим решением. +31. **S-8. Каталог кодов предупреждений** (таблица §11.9 `05-domain-rules.md`); `code` в контракте — строка + `^[A-Z][A-Z0-9_]*$`, не перечисление (новый код — не MAJOR); `VALUE_OUT_OF_RANGE` — `info` (значение и так + `needs_review`). Цена: опечатка кода не ловится схемой — ловится тестами пакета. +32. **S-9. Предупреждение уровня файла** имеет `locator` (json набора ИС, лист). Цена: нулевая. +33. **S-10. `needs_review` полей шапки** — массив `header.needs_review_fields` в контракте и `jsonb` в + `financial_statement_meta`; сейчас туда попадает только `report_date`: сомнения в ИНН и организации — `info` по + дизайну, `needs_review` у них дал бы `needs_check` вопреки «НПЗ ОФР без ИНН остаётся `done`». Цена: экран подсвечивает + ИНН и организацию по `issues` с `field`, а не по `needs_review`. **Утверждено владельцем 18.10.2026 (Р-22)**; формулировка одна во всех документах: + `needs_review_fields` — поля, которые держат документ в `needs_check`; сырой текст и уверенность всех полей шапки + хранятся (`header_source`, S-33). +34. **S-11. Значения `period_kind`:** `absolute`, `relative:current`, `relative:prev_year_end`, + `relative:prev_prev_year_end`, `relative:prev_period`, `none`; «За аналогичный период» — эффективная дата − 1 год + (29.02 → 28.02). Цена: новая относительная подпись — новое значение (MINOR). +35. **S-12. Ожидаемые даты неуверенной подписи колонки** — эффективная дата, она же − 1 и − 2 года **и 31 декабря + предыдущего и предшествующего ему года**. Основание: у квартального ББ колонки «на 31.12 прошлого года», и набор + дизайна (дата, − 1 год, − 2 года) давал бы `error` на каждой такой форме. Цена: чуть шире окно подтверждения. **Утверждено владельцем 18.10.2026 (Р-22).** +36. **S-13. Строка нумерации** — не меньше трёх непустых ячеек, все целые, подряд с 1 слева направо. Цена: нумерация с + пропуском не распознаётся (строка уйдёт в данные со статусом). **Утверждено владельцем 18.10.2026 (Р-22) с оговоркой:** двойники внутри детектора и допуск + одного пропуска — уточнено S-40. +37. **S-14. Признак повторной шапки** — подпись кода в строке или ≥ 2 непустых ячеек, совпавших с шапкой первого + сегмента. Цена: шапка без «Код» и с другими подписями не начнёт сегмент. **Утверждено владельцем 18.10.2026 (Р-22).** +38. **S-15. `ref` и `row_index`:** `row_index` с 0; Excel `ref` строки — адрес ячейки столбца кодов (без столбца — + первой непустой ячейки); разлепленные `row:#` с k от 1; у разлепленных строк `line_code_text` и `text` — + k-е части. Цена: нулевая. Оговорка Р-22: имя листа в Excel-`ref` экранируется по правилу Excel (кавычки, удвоенный апостроф), + примеры контракта исправлены. +39. **S-16. Уверенность кода с заменой двойника** — не выше 99 (так «неуверенный» виден из одного поля). Цена: нулевая. +40. **S-17. Формат PATCH `lines`** — один элемент = одно действие (`line_code` | `line_status: "detail"` | `confirmed`); + коды 422 `INVALID_LINE_CODE`, `LINE_CODE_REQUIRED`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`, `UNKNOWN_COLUMN`; правка суммы + по `line_code` при наличии строки `ok` — 422 `UNKNOWN_CELL`. Цена: имена кодов меняются до реализации без последствий. Оговорка Р-22: доопределено по K-23 (S-47); 409 `LINE_EXISTS` переименован в `LINE_CODE_TAKEN` + (S-43). +41. **S-18. Детали 409:** `LINES_UNRESOLVED` — `detail.lines[] {line_id, row_index, line_code_text}`; `DUPLICATE_FORM` — + `detail.document_id`. Цена: нулевая. +42. **S-19. Имена DBML v5.19:** `document.file_ordinal` (с 0, уникален в файле), `financial_statement_meta.needs_review_fields` + (`jsonb`), `document_issue.line_ids` (`jsonb` — сверка ORM↔DBML не знает массивов), enum'ы `statement_line_status`, + `statement_form_variant`, `document_issue_kind`, `issue_severity`, `issue_source`; индекс `(meta_id, line_code)` остаётся + неуникальным; у строки `is_confirmed` без `confirmed_by/at` (строго по дизайну, кто — в аудите). Новые колонки — последними + в таблицах (как их добавит миграция). Цена: нулевая до миграции. Оговорка Р-22: у `document_issue` добавлены `codes` и `column_index` (S-31, S-35); висячий + `line_id` удалённой шаблонной строки убирается из `line_ids`. +43. **S-20. `related_group.crm_id` уникален** (как у ГК и ЮЛ), повтор — 409 `CRM_ID_EXISTS`. Цена: две группы с одним ID + CRM завести нельзя; так и должно быть. **Утверждено владельцем 18.10.2026 (Р-22).** +44. **S-21. Значения по умолчанию при приёме 1.x:** `ref = null`, `needs_review` строки `false`, `period_kind` — `absolute` + при дате иначе `none`, `form_variant = null`, строки-предупреждения — `LEGACY_WARNING` `info`. Цена: на окне выкладки + документы 1.x без новых сигналов качества. Оговорка Р-22: по K-2 / K-21 — S-36. +45. **S-22. Статус документа ОСВ** — `done`, когда контрольные суммы §1.4 листа сошлись, иначе `needs_check` (прежнее + правило конверта, применённое к листу). Цена: нулевая — конверт ведёт себя как сейчас. **Утверждено владельцем 18.10.2026 (Р-22)**; формулировка «контрольные суммы §1.4 листа сошлись» + теперь одна в контракте, §11, спеках `recognition-contract` и `tabular-recognition-service`. +46. **S-23. `service.statements_version` обязателен**, если в результате есть `statementDocument` (схема проверяет). Цена: + результат только с ОСВ может его не нести. +47. **S-24. Редакция перечня кодов в сервисах** — `fsbu4` при эффективной дате после 2024-12-31 (то же правило, что + `edition` ядра, OQ-145). Цена: досрочный ФСБУ 4/2023 — проверяются обе через «длиннейшую подпоследовательность». Уточнено S-26: проверяются всегда обе, дата решает только ничью. +48. **S-25. Квартал периода (условие `RECOGNIZED`):** дата не конец квартала → `NEEDS_CHECK` (как D-6), ручное + подтверждение — 409 `PERIOD_REQUIRED`. Основание: оговорка дизайна «иначе документ `RECOGNIZED`, но не подтверждаемый» + противоречит автоподтверждению `RECOGNIZED`. Цена: нулевая — поведение как сейчас. **Утверждено владельцем 18.10.2026 (Р-22).** + +### Решения при доработке спеки по ревью K-1…K-25 (18.10.2026) + +Решения агента при закрытии находок `review-2026-10-18-spec.md` там, где ни ревьюер, ни владелец не задали значение, +или где агент отступил от предложения ревьюера. Маркер `S-n` продолжает нумерацию; все обратимы правкой спеки, словаря +или DBML до реализации. + +49. **S-26. Редакции перечня кодов — всегда обе** (K-5): для неуверенного кода считаются наибольшие + подпоследовательности по `66n` и `fsbu4`, берётся длиннейшая, эффективная дата решает только ничью. Основание: + Межениновская за 3 кв. 2025 сдана на бланке 66н — выбор только по дате (S-24) не подтвердил бы её 1120; формула + дизайна «редакция — где длиннее, при равенстве — по дате» иначе не имеет смысла. Цена: на ББ с кодами, общими для + обеих редакций, выбор безразличен; неверный выбор возможен только при равной длине — тогда решает дата, как в S-24. + Уточнено S-65 (лучшая подпоследовательность — сначала по числу уверенных кодов). **Утверждено владельцем 18.10.2026 (Р-25).** +50. **S-27. Перечень кодов — данные в docs** (K-5): источник — `contracts/statement-code-orders.yaml` (обращение «как + контракт»: общие данные сервисов и ядра), в пакете — байтовая копия `rules/statement_code_orders.yaml`, идентичность + проверяет `sync-statements --check`; форма файла — `scripts/validate_contracts.py`, для этого в `make venv` docs + добавлен PyYAML. Цена: ещё одна зависимость docs-проверок (`make venv` нужно перезапустить); перенос источника в + `arm-statements` — правкой одного требования. +51. **S-28. Состав перечня — только подтверждённые образцом коды** (K-5): шаблоны банка + коды печатных форм 1С и сканов + из `fixtures/` (1105, 1130, 1140, 1215, 1231, 1530, 2420, 2460, 2530, 2930) с источником у каждого; раздел III НКО + (1310 «Паевой фонд» … 1370, 1300 — aep_3) — законный повтор `66n`; клиентский 1231 — в `66n` (Унагранде 2024). + Коды без образца (1120 у `fsbu4`, 2421 / 2430 / 2450 у `66n`, упрощённые перечни) — OQ-168, OQ-166 (временное + правило утверждено владельцем — Р-26). Все 12 + последовательностей кодов из `fixtures` (Excel и OCR) укладываются в свою редакцию целиком. Цена: на скане с + неподтверждённым кодом и неуверенностью ниже 100 — `needs_review` строки; у Excel и уверенного кода — только `info`. +52. **S-29. Окно перехода контракта** (K-8): схемы 1.2 / 1.0 лежат в docs как `*.v1.schema.json` (байтовые копии, + сервисы сверяются с ними без правки копий), примеры — `examples/v1/`; тест идентичности сервиса выбирает файл по + `$id` копии (`…/v1` → `*.v1.schema.json`) — это шаг 0 выкладки в tabular-parser и scan-gateway до слияния docs. + Отступление от предложения ревьюера «тест сверяет с MAJOR, который сервис пишет»: признак — `$id` копии, а не + настройка сервиса, чтобы правило работало без знания кода сервиса. Цена: маленький PR в двух сервисах до слияния + docs; без него их `make check` красный на окне. **Заменено S-53** (повторное ревью L-2: файла `.v1` у запроса нет, + примеры и ядро не были покрыты). +53. **S-30. Механика `sync-statements`** (K-9): копия — `api/arm_statements/` (импорт как `src`), тесты пакета в + `testpaths` хоста, `mypy src arm_statements`; маркер — файл `api/arm_statements/VERSION` с полным хэшем; `--check`: + `0` синхронно, `10` отстала (зелёный с предупреждением — иначе любой посторонний PR в сервис требовал бы + подтягивать чужие изменения пакета), `20` прямая правка / чужая версия / расхождение перечня кодов / код словаря ядра + вне перечня, `2` нет checkout или аргумент. Без checkout `arm-statements` `check-full` красный (код 2): это проверка + перед PR, а не `make check`. Цена: отставшая копия проходит `check-full`; видна в описании PR. +54. **S-31. `column_index` — номер столбца сетки кандидата** (K-6): у OCR — `column_index` ИС как есть (разреженный), у + Excel — номер столбца листа первого сегмента с 0; у `document_issue` — `column_id` для колонки формы и + `column_index` для столбца вне колонок; `line_refs` — только строки `lines[]`. Отступление от варианта «порядковый + номер колонки значений»: иначе пропущенная колонка (`COLUMN_SKIPPED`) не имела бы номера. Примеры контракта + приведены к правилу (Excel — 3, 4; OCR — 2). Цена: номера не с 0 и не подряд — экран и ядро не должны считать их + позициями. Дополнено S-56 (колонки сегментов со своей шапкой). +55. **S-32. `TABLE_PARSE_ERROR` и `SHEET_OTHER` — предупреждения файла** (K-7): с `locator`; документа нет; + `TABLE_PARSE_ERROR` (`error`) блокирует `RECOGNIZED` всех документов файла, как `JSON_FAILED`. Цена: один упавший + разбор таблицы держит весь файл на ручном подтверждении. +56. **S-33. Шапка: `header_source jsonb` + прежние `*_original_value`** (K-3): сырой текст и уверенность всех десяти + полей — одной колонкой `{поле: {text, confidence}}`; `inn_original_value`, `report_date_original_value`, + `units_original_value` заполняются как прежде, `inn_confidence` остаётся. Отступление от варианта «колонки на каждое + поле»: 20 колонок ради подсказки на экране. Цена: поиск по сырому тексту поля шапки в SQL — через `jsonb`. +57. **S-34. `organization_name` в PATCH шапки** (K-12): непустая строка до 1000 символов, `organization_name_modified`, + `organization_name_original_value` (новые колонки DBML), пересчёт `counterparty_check`, аудит; снимает + `NO_ORGANIZATION_NO_INN`. Выбран вариант «добавить», а не «убрать из правила снятия»: иначе форма без шапки + снималась бы только перезагрузкой. Цена: правка наименования влияет на сверку контрагента по алиасам. **Утверждено владельцем 18.10.2026 (Р-25).** +58. **S-35. Коды нарушений в `document_issue`** (K-13): `relation` → `RELATION_FAILED` / `error`, `soft` → + `RELATION_SOFT` / `info`, колонка `codes jsonb`, `line_ids` — все строки `ok` кодов. Цена: нулевая. +59. **S-36. Формы без `checks`** (K-2, K-21): у результата 1.x и у форм до миграции ядро один раз считает §7 копией + пакета (`form_variant = null` — полный набор) и пишет `document_issue` (`source = core`): при приёме 1.x и **в + ревизии миграции** (ревизия импортирует копию `arm_statements`). D-6 «проверки сошлись» для 1.x — из этого расчёта; + `GET` и `confirm` читают нарушения только из `document_issue`. Предупреждения маппера 1.x по документу — + `MAPPING_WARNING` `info`; строки `metrics.warnings` 1.x — `LEGACY_WARNING` в `recognition_job.metrics` (уровень + файла, не `document_issue`); `STATEMENTS_VERSION_MISMATCH` при `statements_version = null` не пишется. Цена: ревизия + миграции зависит от кода пакета на момент миграции (старая ревизия при смене пакета считает по новым правилам — для + разовой миграции приемлемо); отказ от миграционного расчёта дал бы потерю нарушений у существующих форм (K-2). + **Утверждено владельцем 18.10.2026 (Р-25) в редакции Р-24:** миграционного расчёта нет — база перед выкладкой + очищается от форм; расчёт при приёме 1.x остаётся (вариант формы — по структуре кодов, S-59). +60. **S-37. Признак строки правки — `row_index IS NULL`** (K-1): маппер 1.x и 2.0 всегда пишет `row_index`; миграция + ставит `NULL` строкам с `line_code_original_value IS NULL`; `line_code_original_value` — код из источника, правками + не меняется. Отступление от дизайна («смена кода — прежний в `line_code_original_value`»): при первой смене это то + же значение, а при повторной правке исходный код источника не теряется. Цена: «предыдущий код» после двух правок — + только в аудите. **Утверждено владельцем 18.10.2026 (Р-25).** +61. **S-38. Функция статуса и «содержимое» строки** (K-14): вход — данные без типов источника (`layout_found`, у ядра — + есть строка из источника; `has_organization_or_inn`; `period_known`; статусы и `needs_review`; число жёстких + нарушений; `accepted`; уровни предупреждений). Содержимое строки — цифра в ячейке кода, сумма в значениях или + непустая ячейка значений с уверенностью ниже 100; строка `unreadable` без значений («44 30\nI |» aep_6) — строка + данных: документ `needs_check`, подтверждение не блокирует. Цена: такая строка требует от аналитика «Это + расшифровка» или кода, хотя сумм в ней нет. +62. **S-39. Столбец кодов ищется всегда, и у `DISCLOSURE`** (K-10): `type_hint = DISCLOSURE` при кодах ББ/ОФР → тип по + кодам + `TYPE_CONFLICT`; у `DISCLOSURE` шаги 3–7 не выполняются, `columns` / `lines` — пустые массивы. Цена: лишний + проход поиска столбца по пояснениям. +63. **S-40. Детектор нумерации** (S-13 с оговоркой Р-22, K-15): ≥ 3 непустых ячеек, каждая после замены двойников — + целое; возрастают на 1, начинаются с 1 или 2, допускается один пропуск. Цена: настоящая строка данных из трёх + маленьких целых подряд («1 | 2 | 3» в суммах) станет нумерацией — на формах ББ/ОФР не встречалась. +64. **S-41. `LINES_UNRESOLVED` — сумма или текст** (K-16): блокирует строка `unreadable` / `missing`, у которой хотя бы + в одной ячейке `amount` или `string_value` не `null`. Цена: строка с мусором вместо суммы требует разбора. **Утверждено владельцем 18.10.2026 (Р-25).** +65. **S-42. Подтверждение неуверенной даты отчёта** (K-17): PATCH `header.report_date` тем же значением у поля из + `needs_review_fields` снимает его и `REPORT_DATE_UNCERTAIN`; у бланка 66н только с относительными подписями это + единственный путь — признано в §11.9. Цена: аналитик должен явно подтвердить дату на каждом таком бланке. +66. **S-43. Имена** (K-18): 409 `LINE_EXISTS` → `LINE_CODE_TAKEN` (имя занято сообщением 422 `UNKNOWN_CELL` в коде + ядра); `period_end` колонки → `period_date` во всех документах (как в контракте и DBML); порядок `lines` в API — + «по возрастанию кода, как сейчас», «как на бумаге» — раскладка экрана; `DISCLOSURE` — пустые массивы. Цена: нулевая. +67. **S-44. Откат миграции** (K-19): гарантирован на пустой и совместимой с v5.18 БД; на несовместимых данных + `downgrade` останавливается с ошибкой и ничего не удаляет. Цена: откат после появления данных v2.0 — только + восстановлением из резервной копии. +68. **S-45. «Того же периода»** (K-20) — та же пара `period_year`, `period_quarter` (как автозамена §9.5 п. 3); формы + без квартала не считаются (их и так держит `PERIOD_REQUIRED`). Цена: нулевая. +69. **S-46. Единицы и ОКЕИ** (K-22): словарь `statement_units.yaml` в пакете (написания и коды ОКЕИ 383 / 384 / 385); + единицы по тексту, иначе по ОКЕИ; `okei` (+ `_text`, `_confidence`) — в `header` контракта v2.0 и `headerField`. + Цена: ОКЕИ, противоречащий тексту единиц, не ловится (текст главнее). +70. **S-47. Пробелы PATCH** (K-23, S-17): `confirmed: false` снимает подтверждение ячейки и кода; `line_status` принимает + только `"detail"` (`"ok"` — 422 `LINE_CODE_REQUIRED`); обратного `detail` → `unreadable` / `missing` нет (исправляется + назначением кода, исходное — перезагрузкой); `columns[].period_date = null` — 422 валидации; **акцепт сбрасывает + правка данных** (сумма, код, статус строки, дата колонки, поле шапки), подтверждения (`confirmed`) — нет. Последнее + сужает правило Среза 3b «акцепт сбрасывается любой правкой формы» — вынесено владельцу (отчёт O-n). Цена: если + владелец считает подтверждение правкой — одна строка спеки. **Утверждено владельцем 18.10.2026 (Р-25):** + подтверждение без правки акцепт не сбрасывает. +71. **S-48. Страховка `done`** (K-24): `done` из результата при строке `unreadable` / `missing` со значением, + `needs_review`, `relations_failed` или `error`-предупреждении документа → `NEEDS_CHECK` и `RESULT_STATUS_INCONSISTENT` + `info`. Цена: проверка на каждом маппинге — линейная по строкам. **Утверждено владельцем 18.10.2026 (Р-25) в + редакции L-12:** согласованность — вызов общей функции статуса по входу из результата (не список признаков). +72. **S-49. Словарь имён ядра** (Р-20, S-4): повтор кода в редакции разрешён — все имена кода синонимы; шаблонная строка + и `reference_name` берут первое; «итого по разделу» — только равенство. Цена: справочное имя законного повтора на + экране — первое имя кода, файловое — рядом. +73. **S-50. Толкование Р-21:** мягкое соотношение, сошедшееся в пределах ±1, покрывает значения любого своего кода; у + кода в несошедшемся жёстком соотношении подсветка остаётся (правило «все ячейки несошедшегося жёсткого»), поэтому + на практике Р-21 действует на строки без жёстких соотношений, как сформулировал владелец. Цена: нулевая. +74. **S-51. Ожидания на реальных сканах признаны в §11.9 и proposal** (K-4): большинство из шести OCR-документов + останутся `needs_check`; формулировка гайда фронта смягчена («может стать `RECOGNIZED`»). Цена: нулевая. + **Пересмотрено Р-23:** значения `detail` статус не держат, ББ и ОФР РБ и Счастливочной за 1 кв. 2025 могут стать + `done`; держат `needs_check` aep_3 и aep_6. +75. **S-52. Манифест tabular-parser** (K-25): в спеке `tabular-recognition-service` перечислены записи, получающие + `1600 = 1700` (страницы 2 баланса ×3), и записи, чьи статусы `done` меняться не должны. Цена: нулевая. + +### Решения при доработке спеки по повторному ревью L-1…L-30 (18.10.2026) + +Решения агента при закрытии находок `review-2026-10-18-spec-2.md` там, где ни ревьюер, ни владелец (Р-23…Р-26) не задали +значение, или где агент отступил от предложения ревьюера. Маркер `S-n` продолжает нумерацию; все обратимы правкой спеки, +словаря или DBML до реализации. + +76. **S-53. Правило теста идентичности копии контракта** (L-2; заменяет S-29): схема копии с `$id` `…/v` сверяется с + `<имя>.v.schema.json` docs, **если такой файл есть**, иначе с основным; пример — с `examples/v/<имя>.json`, если + он есть, иначе с `examples/<имя>.json` (`M` — `MAJOR` копии схемы вида примера, вид — по префиксу имени); прочие файлы + копии (`.v`, `examples/v/` у ядра) — с одноимёнными; файлы docs другого `MAJOR` сервис не сверяет. Правило + вводится в шаг 0 у **всех трёх** потребителей, включая ядро. Основание: существующие тесты (`arm-core/api/tests/ + test_contract.py`, `tabular-parser` и `scan-gateway` `api/tests/unit/test_contract.py`) сравнивают весь набор + `*.json` через `rglob`, включая `examples/`; правило «файл, если есть» проходит до слияния docs (файлов `.v1` нет), на + окне и после, у запроса без `.v1`. Цена: три маленьких PR до слияния docs; удаление `MAJOR` 1 — в порядке «сначала + ядро убирает копии `.v1`, затем docs». +77. **S-54. `file_ordinal` у всех документов** (L-3): индекс документа в `documents[]` результата при любом `MAJOR`, у + ОСВ, форм, `DISCLOSURE` и `OTHER`; несозданный документ (второй лист ОСВ с тем же счётом) номер не занимает и других + не сдвигает — номера с пропусками. Цена: номер не «порядковый без дыр»; экран его не показывает. +78. **S-55. Предупреждения файла и ОСВ в ядре** (L-4): `recognition_job.metrics.warnings` и `JobBrief.warnings` — + структуры `fileWarning`; строки старых заданий конвертируются при чтении в `LEGACY_WARNING` (данные не + переписываются); `JobBrief.mapping_warnings` — строки, как были. Предупреждения листа ОСВ 2.0 — `document_issue` + документа ОСВ, отдаются в новом `DocumentOut.issues[]`. `osvDocument.status` используется: ОСВ 2.0 `RECOGNIZED` ⇔ + `status = done` ∧ `checks` чисты ∧ нет `error` файла ∧ контрагент не `MISMATCH` ∧ нет предупреждений маппинга листа; + статус конверта не входит (как Р-19 для форм). Отступление от гайда «поля только добавляются»: тип `JobBrief.warnings` + меняется — записано в гайде фронта. Цена: фронт обязан перейти на объекты в той же выкладке, что и ядро; ОСВ в книге + с сомнительной ОФР теперь автоподтверждается (раньше держалась статусом задания). +79. **S-56. Колонки сегмента со своей шапкой** (L-6): `columns[]` задаёт первый сегмент; колонки следующего сегмента со + своей шапкой сопоставляются с ними по `period_date` (если у каждой колонки сегмента дата есть и совпадает ровно с + одной), иначе по порядку; при сопоставлении по порядку другое число колонок — `SEGMENT_COLUMNS_MISMATCH`, лишние + столбцы сегмента в `columns[]` не попадают, их номера — в `message`. Цена: сегмент с переставленными колонками без дат + сопоставится по порядку неверно — §7 это поймает; НПЗ (лист 4 ББ со своей шапкой) остаётся `done`. +80. **S-57. Явное правило мусора в `parse_amount`** (L-7): «ядро числа» от первого «цифра, `(`, знак» до последнего + «цифра, `)`, `}`, `]`»; всё вне ядра — краевой мусор, выбрасывается с `is_recognized = false`; в ядре — только цифры, + пробелы, `,`, `.`, знак в начале и пара скобок вокруг числа, иначе `amount = null` + `string_value` (буква в ядре, + непарная скобка, символ между цифрами); крайние `|` удаляются до замены двойников; `{` и `[` открывающей скобкой не + считаются. Так `211(1 |` и `(Ю4)` дают `null`, `: : 2856983 = ■` — 2856983, `{38378)` — `null` (сценарий «знак вне + конвенции» переписан на значение без знака). Отступление от сегодняшнего `amounts.py` (выбрасывал всё, кроме цифр): + осознанно. Цена: «12:34» даёт `null` и требует внимания аналитика; «1 234 руб.» даёт 1234 с `is_recognized = false` + (буквы после последней цифры — краевой мусор, значение нуждается в покрытии §7). +81. **S-58. Дрейф перечня кодов — код 10, не 20** (L-8): `sync-statements --check` в хосте сверяет копию перечня с + docs, расхождение — `10` (предупреждение), а не `20`; `20` — только прямая правка копии, чужая версия, недостающая + зависимость пакета и (в ядре) код словаря имён вне перечня **копии**. Порядок правки перечня: docs → `arm-statements` → + хосты. Цена: копия с устаревшим перечнем проходит `check-full` с предупреждением; видна в описании PR. +82. **S-59. Точка входа пакета для пересчёта** (L-10): `StoredForm` (без сетки) → `{form_variant, edition, checks, + needs_review строк и значений, родители detail, status}`; признаки `code_fixed` / `value_fixed`, строки + `row_index = null` участвуют в §7, `needs_review_fields` — вход (пересчёт шапку не переоценивает). Ядро вызывает её + после PATCH и при приёме 1.x; вариант формы при приёме 1.x — **по структуре кодов**, а не `null` (уточняет S-36: иначе + упрощённая форма 1.x получила бы нарушения полного набора до первой правки, а после правки — другой набор). Цена: + у формы 1.x `form_variant` может отличаться от «полного по умолчанию»; образца упрощённой формы нет (OQ-166). +83. **S-60. «Итого по разделу» — сравнение номеров раздела** (L-11): номер — начальные буквы `i`/`v`/`x` первого слова + после фразы (хвост OCR «V1», «V /» отбрасывается), `strip` явно; номера равны ⇔ совпало; номер не выделен — равенство + строк. Отступление от S-49 («только равенство»): ломалось на «Итого по разделу V1» Счастливочной. Цена: «II», + прочитанное как «I1», даст I ≠ II — ложное `NAME_MISMATCH` (безопасная сторона). +84. **S-61. «изо :» — пустой код, правило не меняется** (L-14): двойники цифрами в ячейке кода без цифр не считаются + (иначе слова, затёкшие в столбец кодов, стали бы `unreadable` и блокировали подтверждение); пример в §11.4 + исправлен. Цена: потерянная строка 1130 aep_3 («изо :», `'3'` с уверенностью 0) уходит в `detail` и скрыта; её сумма + не входит в 1100, и `1100 = …` подсвечивает раздел, если сумма больше допуска ±1. +85. **S-62. Родитель `detail`** (L-15): ближайшая строка `ok` выше (`unreadable` / `missing` пропускаются; нет `ok` выше — + строка `missing`); после каждого PATCH ядро пересчитывает родителей **всех** строк `detail`; нет `ok` выше — родитель + `null`, статус остаётся `detail`. Цена: строка `detail` без родителя на экране — в начале раздела. +86. **S-63. Дата колонки руками** (L-16, L-17): ручная дата ставит `period_kind = absolute`; дата, равная текущей, у + колонки с `COLUMN_DATE_UNCERTAIN` / `COLUMN_WITHOUT_DATE` — подтверждение (аналог S-42), снимает предупреждение и + акцепт не сбрасывает. Цена: после ручной даты относительная колонка не следует за правкой даты отчёта — так и нужно. +87. **S-64. Подтверждение дат только уверенными источниками** (L-18): для неуверенной подписи колонки эффективная дата — + дата отчёта с уверенностью 100, иначе самая поздняя абсолютная дата колонок с подписью уверенности 100; неуверенная + дата отчёта подтверждается только датами уверенных подписей. Для периода и относительных подписей эффективная дата — + по всем источникам. Цена: скан, где и дата отчёта, и все подписи неуверенны, — всегда `needs_check` с подтверждением + даты руками. +88. **S-65. Структурная проверка кода — уточнения** (L-19): «лучшая» подпоследовательность — сначала по числу уверенных + кодов (якоря), затем по длине; коды вне перечня редакции в её последовательность не входят; противоречащие якоря — + входит наибольшее согласованное их множество; неуверенный код подтверждён, если входит во все лучшие; вариант формы — + по всем строкам `ok` и пересчитывается ядром после правки кода. Цена: при перепутанных уверенных соседях неуверенный + код между ними может остаться неподтверждённым — `needs_review`, безопасная сторона. +89. **S-66. Форма знака** (L-20): по результату разбора (скобки, включая `}` / `]`; минус; без знака); сама ячейка в + голосование не входит; нужно строгое большинство, равенство — не подтверждён. Цена: колонка с двумя голосующими + разной формы не подтверждает знак ни одной сомнительной ячейки. +90. **S-67. Редакция для проверок ядра** (L-21): `CODE_NOT_IN_EDITION` и `CODE_NOT_IN_DICTIONARY` — по редакции точки + входа (лучшая подпоследовательность по всем строкам `ok`, ничья — по дате); `edition` ответа API и шаблонные строки — + по дате, как сейчас. Цена: на бланке 66н, сданном в 2025 году, шаблонные строки на экране — по `fsbu4`, а проверки — по + `66n`; сведение — отдельной задачей при необходимости (O-1 отчёта). +91. **S-68. `OTHER` удалён из v2.0, `form_variant` обязателен** (L-22): в схеме v2.0 `statementDocument.document_type` — + `BALANCE` | `INCOME` | `DISCLOSURE`, `form_variant` — в `required` (`null` у `DISCLOSURE`); `OTHER` остаётся в схеме + 1.x и в `document_type` ядра. Цена: нулевая — производителей `OTHER` в 2.0 нет. +92. **S-69. Откат миграции — точная формулировка** (L-24; уточняет S-44): `downgrade` останавливается только на данных, + которые v5.18 не может представить без потери первичных данных и ручного ввода (повтор кода, строка без кода, две + формы одного типа в файле, непустой `crm_id` групп, правленое наименование организации); производные и сигнальные + колонки v5.19 и `document_issue` удаляет с записью числа удалённых значений в журнал миграции (подтверждения — в + аудите). Цена: откат после работы аналитиков теряет подтверждения ячеек и кодов как признаки (история — в аудите). +93. **S-70. Механика копии пакета — уточнения** (L-25): `VERSION` из сравнения исключён (в источнике его нет); зависимости + пакета из его `pyproject` обязаны быть в зависимостях хоста (иначе `--check` = 20); пакет проходит самый строгий + конфиг `ruff` / `mypy` среди хостов; тесты пакета со своим `conftest`, `autouse`-фикстуры хоста ограничены его + каталогом `tests/`. Цена: пакет живёт по самым строгим правилам всех хостов. +94. **S-71. Содержимое таблицы ИС и `TABLE_SKIPPED`** (L-27): содержимое — хотя бы одна ячейка с цифрой или «цифрой под + видом буквы»; таблица только из текста без столбца кодов и типа — новое предупреждение файла `TABLE_SKIPPED` (`info`), + а не `TABLE_LOST` (`error`). Цена: текстовая таблица ИС не блокирует автоподтверждение; если в ней была форма без + единой цифры — такой формы не бывает. +95. **S-72. «Цифра под видом буквы» — содержимое строки** (L-30): ячейка значений, текст которой после замены двойников + без условия соседства содержит цифру и не содержит других букв (`'I'`, `'| О'`), делает строку строкой данных; текст + из настоящих букв при уверенности 100 (подписи под формой Excel: «Руководитель … Косарев», «(расшифровка подписи)» у + Межениновской) — нет: иначе каждая книга получала бы мусорные строки `detail`. Признано осознанным исключением из + «ничего не выбрасывать молча». Цена: текстовая строка без цифр с уверенностью 100 по-прежнему теряется молча. +96. **S-73. Очистка форм перед выкладкой — детали** (Р-24): скрипт ядра `scripts/purge_statements.py`, `--dry-run` по + умолчанию, `--apply` одной транзакцией; отказ без изменений, если форма упомянута в `ex_snapshot_item` или привязана к + версии не «в работе»; файлы только с формами удаляются целиком (как `DELETE /files/{id}`, без запрета + `FILE_HAS_CONFIRMED_DOCUMENTS`, задания — тоже); в смешанных файлах «ОСВ + формы» удаляются только формы, файл + остаётся — повторно его не загрузить (дедуп `sha256`), поэтому `--dry-run` перечисляет их и владелец решает про + `--include-mixed`; системный аудит `FILE_DELETE`. Цена: подтверждённые формы и их привязки к версиям «в работе» + исчезают до повторной загрузки; КФО этих версий пересчитывается. +97. **S-74. `needs_review` значений после выхода строки из `detail`** (Р-23): строка, которой назначили код, получает + `needs_review` значений по общему правилу (покрытие §7); строка, отмеченная `detail`, — `false`; значения + `unreadable` / `missing` считаются по общему правилу (документ и так `needs_check` по статусу строки). Цена: нулевая. + +### Решения при планировании и исполнении плана 0 (18.10.2026) + +98. **P0-1. Правило теста идентичности — копией в трёх репозиториях**: правило живёт файлом `api/tests/contract_identity.py` + сценарный тест, побайтно одинаковыми в arm-core, tabular-parser и scan-gateway, а не общим пакетом и не скриптом docs; эталон — копия arm-core, расхождение ловит `shasum` (план 0, задача 5) и ревью; соседние репозитории в `make check` не читаются. Цена: правка правила — три PR; разошедшиеся копии до сверки не видны. +99. **P0-2. «Копия держит MAJOR N»**: в копии есть хотя бы один файл `*.v.schema.json` или `examples/v/*.json`; тогда все такие файлы docs обязательны в копии. Цена: ядро, положившее один файл `.v1`, обязано положить все. +100. **P0-3. Неопределимая пара — ошибка**: пример с неизвестным префиксом, схема без `$id` вида `…/v`, пример вида, схемы которого нет в копии — ошибка теста с именем файла, а не пропуск. Цена: новый вид примера в docs требует правки правила. +101. **P0-4. `ARM_KA_DOCS` в scan-gateway**: тест идентичности scan-gateway читает `ARM_KA_DOCS` (спека называет переменную); поиск `fixtures` не меняется. Цена: расхождение в `dev/checks.md` сужается до fixtures. +102. **P0-5. OpenSpec change `contract-identity-window` в tabular-parser и scan-gateway**: MODIFIED требования «Копия контракта идентична источнику» (прежний текст «байт в байт равны `../docs/contracts/`» на окне неверен); arm-core change не получает — правило описано в спеке docs `recognition-contract`. Цена: у arm-core при закрытии нет `openspec archive`. +103. **R-1 (план 0). Дорожку плана 0 исполняет фоновый агент на sonnet**: а не сама сессия: сессия координирует четыре параллельные дорожки (планы 0, 1, 3А, 6). Метод — executing-plans (Native, ≤5 задач), ревью всей ветки — opus. Цена: слабее контекст сессии при финальных рулингах; страхует ревью и таблица находок. +104. **P1-1.** Конфиг линтеров пакета — только в корневом `pyproject.toml` репозитория `arm-statements` (в дерево `arm_statements/` не входит и в хост не копируется): `ruff.toml` внутри `arm_statements/` по иерархии ruff перекрыл бы конфиг хоста, а спека требует «конфиг хоста применяется к копии». Код пакета написан под самый строгий конфиг, так что он проходит любой хост. Цена: «конфиг линтеров вместе с копией» (формулировка спеки) читается как «код, проходящий конфиг», а не как файл; см. O-1. +105. **P1-2.** Версии инструментов пакета — пины scan-gateway (`ruff==0.16.10`, `mypy==2.4.0`, `pytest==9.1.1`): это единственный хост с явными пинами; у tabular-parser и arm-core `>=`, в их окружениях встречаются ruff 0.16.10–0.17.1. Цена: новая версия ruff в хосте может найти новое замечание в копии; см. O-2. +106. **P1-3.** `PLR2004` в тестах пакета не отключается: `per-file-ignores` хостов (`tests/**` у scan-gateway) на копию `api/arm_statements/tests/` не распространяются. Цена: числа в тестах — кортежами и `D("…")`. +107. **P1-4.** Ничья редакций при `effective = None` — `edition = None`, неуверенный код подтверждён только при подтверждении в обеих редакциях. Цена: лишняя подсветка кода у формы без даты (она и так `needs_check` — `PERIOD_UNKNOWN`). +108. **P1-5.** «Книга xlsb без числовых форматов» шаг 4 узнаёт по предупреждению адаптера `XLSB_NO_FORMATS` в `FormCandidate.warnings` — отдельного поля у кандидата спека не задаёт. Цена: адаптер Excel обязан класть это предупреждение в кандидат каждой формы книги xlsb. +109. **P1-6.** У `DISCLOSURE` предупреждения шапки не выдаются, статус всегда `done` (спека: «`DISCLOSURE` — `done` без условий»). Цена: пустая шапка пояснений не видна предупреждением. +110. **P1-7.** Код Excel из текстовой ячейки с заменой двойника — неуверенный (≤ 99) и проходит структурную проверку, как OCR; «у Excel `needs_review` по структуре не ставится» прочитано как «у чистых кодов Excel» (уверенность 100). Цена: редкий текстовый код «I1О0» в Excel может подсветиться. +111. **P1-8.** Строка правки (`row_index = None`) у ядра участвует в §7 и в выборе варианта, только если у неё есть сумма; в структурной последовательности и как родитель `detail` — нет (позиции в файле нет). Цена: код правки не служит якорем для подтверждения соседних неуверенных кодов. +112. **P1-9.** Двухстрочный заголовок склеивается, когда подпись верхней строки отдельно — не период, а «верхняя + нижняя» — период («На 30 сентября» + «2025 г.»; нижняя «2025 г.» сама по себе дала бы ошибочное 31.12.2025). Цена: заголовок в два ряда без даты не склеивается (колонка всё равно остаётся по числам); если над строкой заголовка стоит посторонний текст, который вместе с нижней подписью случайно читается как период, подпись склеится. +113. **P1-10.** Текстовая сумма без пробелов длиннее 16 целых цифр — `amount = null`, `string_value` = текст, `is_recognized = false`, без `VALUE_OUT_OF_RANGE` (предупреждение — только для числового `value` Excel, как в спеке). +114. **P1-11.** `COLUMN_DATE_UNCERTAIN` проверяется только у абсолютных подписей: относительные даты вычисляются от эффективной даты и в ожидаемые входят по построению. +115. **P1-12.** Поля шапки, правила которых спека не задаёт: `okpo`, `okopf`, `okfs` — только цифры; `okud` — первое совпадение `0710\d{3}`; `organization_name`, `address` — текст без пробелов по краям. +116. **P1-13.** В словарь пакета `statement_headers.yaml` переезжают и метки шапки Excel (`labels`, `auditor`, `legal_form`) — proposal: «`statement_headers.yaml` переезжает в пакет»; у tabular-parser не остаётся своего словаря форм. Цена: в общем словаре есть данные, которыми пользуется один адаптер. +117. **P1-14.** «Под подписью» в правиле (а) столбца кодов — ячейки столбца ниже подписи до конца сегмента. +118. **P1-15.** `FailedRelation` несёт и `column_index`, и `column_key` — чтобы ядро получало нарушения в своих ключах колонок без обратного сопоставления. +119. **P1-16.** Перечень кодов копируется из ветки docs `feat/116-unified-normalization` до слияния docs PR #117. +120. **P1-17.** Столбец правее кода без подписи, но с числами, в сегменте с заголовком — колонка с `label = ""`, `period_kind = none` и `COLUMN_WITHOUT_DATE` (`error`): «числовая колонка без разобранной подписи» по таблице уровней. +121. **P1-18.** Заголовок есть у одного из сегментов, но не у первого — колонки документа задаёт первый сегмент с заголовком (спека: «остальные сегменты берут его колонки»), `column_index` — номера столбцов этого сегмента. +122. **P1-19.** Документ `BALANCE` / `INCOME` без строк (`CODE_COLUMN_NOT_FOUND`) получает `form_variant = full`: в схеме v2.0 `form_variant` обязателен и `null` только у `DISCLOSURE`. +123. **P1-20.** При `accepted` (у ядра) правило «`needs_review` у всех ячеек несошедшегося жёсткого соотношения» не применяется: иначе акцепт снимал бы нарушение со статуса, а подсвеченные им ячейки держали бы `needs_check` через условие «нет значений с `needs_review`» (спека: «`accepted` ⇒ жёсткие нарушения статус не держат»). У сервиса `accepted` всегда ложно. Цена: после акцепта ячейки несошедшегося соотношения не подсвечиваются (нарушение видно в `checks`). +124. **P1-21.** `FailedRelation.expected` / `actual` (и `RelationOutcome`) — `Decimal | None`: `None` — цель или все члены пусты; такое посчитанное соотношение — всегда нарушение (допуск не применяется). Контракт v2.0 так и задаёт (`expected` — `money`, «`null` — цели нет»; `actual` — `money`), агрегат ядра тоже не превращает пустое в 0 (план 3, Review Focus 2). Цена: хосты и ядро обрабатывают `None` в тексте нарушения («пусто»); при ошибке — `0` вместо пустоты прятал бы «пустую цель» в допуск ±1 (`1600 = 0` при пустом `1700` считалось бы сошедшимся). +125. **P1-22.** Две публичные нормализации текста: `normalize_label` (дословно `normalize_text` tabular-parser — пунктуация сохраняется; под неё записаны перенесённые из tabular-parser метки шапки, заголовки, ОКУД, маркер продолжения) и `normalize_caption` (подписи колонок, единицы, относительные периоды). Таблица «ключ словаря → нормализация» — в разделе API; `type_hint` и `is_continuation` пакета пользуются `normalize_label`. Цена: новый синоним пишется под нормализацию своего ключа; ошибка — синоним молча не срабатывает (ловят тесты пакета на синтетике и покрытие словарей в tabular-parser, O-8 плана 4). +126. **P1-23.** Новый код `XLS_NO_CODEPAGE` (`info`, файл): книга xls без кодовой страницы — заметка читателя, относящаяся ко всей книге (и к формам, и к ОСВ). Строки добавлены в таблицу спеки «Шаг 8 — предупреждения» и §11.9 `05-domain-rules.md` (правило спеки: «новый код вводится словарём пакета и таблицей §11»). Цена: tabular-parser выдаёт его в `metrics.warnings` вместо `OSV_XLS_NO_CODEPAGE` в каждом документе ОСВ (план 4, P4-13); экран показывает его в карточке файла, а не у листа ОСВ. +127. **P1-24.** `resolve_relative(ABSOLUTE | NONE, …)` → `None`, а не исключение: у таких колонок дата не вычисляется, а ядро перебирает колонки формы одним циклом. Цена: вызов с неверным видом не заметен (ядро зовёт функцию только для `relative:*`, план 3 B2). +128. **P1-25.** `choose_edition(document_type, codes: Sequence[tuple[str, bool]], effective, rules, *, variant=None)` вместо запрошенной `choose_edition(effective, codes, rules)`: перечень кодов зависит от формы и варианта, а «лучшая подпоследовательность» по спеке сравнивает сначала число **уверенных** кодов — без типа и признака уверенности результат расходился бы с `recompute`. `confirm_codes` и `recompute` берут редакцию этой же функцией. Цена: ядро передаёт пары `(код, уверенный)`, собранные из строк результата (`line_code_confidence == 100`). +129. **P1-26.** Перевод выхода в форму контракта v2.0 (`to_contract`, `warning_to_contract`) — в пакете, а не в каждом сервисе: одно уплощение шапки, одно правило «необязательные ключи без `null`», один отброс `column_key`. Пакет теперь знает форму `statementDocument` v2.0. Цена: новый MAJOR контракта меняет и пакет (синхронизация копий — план 2), а модель контракта хоста валидирует `dict` пакета — расхождение видно тестом схемы хоста, а не пакета. +130. **P1-27.** Статус документа — функция документа: `status_input(StatementDocument)` (конвейер считает им `status`) и `status_input_from_contract(dict)` для ядра (страховка `document.status` результата 2.0); свойство `status_input_from_contract(to_contract(d)) == status_input(d)` закреплено тестами задач 19 и 20. `layout_found` выводится из «строки есть» (строк нет ⇔ столбец кодов не найден, задача 17 п. 2). Цена: если в будущем появится документ со столбцом кодов, но без строк, функции нужен явный признак раскладки. +131. **P1-28.** `code_column(rows, rules, *, relaxed)` и `amount_columns(rows, code_col, rules, *, source)` — публичные обёртки шага 2 над сеткой `Cell` (адаптер Excel строит её из листа); минимум ослабленного поиска и слабого продолжения — данные словаря (`min_codes_continuation: 1`, был литерал «минимум один» в коде шага 2 и `min_codes_continuation` tabular-parser). `relaxed` задаётся типом формы, а не внутренним `CodeSection`. Цена: адаптеру нужна сетка `Cell` листа, чтобы спросить столбец кодов (у tabular-parser она уже есть — `grid_rows` плана 4). +132. **P1-29.** `form_name` — поле `StatementHeader` (в контракте он в `header`), а не `StatementDocument`; ставит конвейер из `evidence.title.text` как есть, `parse_header` оставляет `None`. Цена: хост, собирающий шапку `parse_header` сам (без конвейера), заголовка не получит — таких вызовов в планах нет. +133. **P1-30.** Коды предупреждений — `WarningCode` (`StrEnum`) в `issues.py`; загрузчик требует равенства множества кодов словаря и перечисления; `make_warning` с неизвестным кодом — `KeyError`. Цена: новый код — две правки в пакете (словарь + член перечисления) и строка §11.9 docs; опечатку в коде хоста ловит mypy (член перечисления), а строковый литерал — только `KeyError` при вызове. +134. **P1-31.** `Rules.code_orders.sequence(…, DISCLOSURE, …)` → `()`, а не `KeyError`: у пояснений перечня нет, и структурная проверка, и проверки ядра по редакции к ним не применяются. Цена: вызов для пояснений молча даёт пустой перечень. +135. **R-1 (план 1). Дорожка плана 1 — фоновый SDD-контроллер на sonnet**: дорожки волны 1 (планы 0, 1, 3А, 6) идут параллельно четырьмя фоновыми агентами; исполнители задач — sonnet, финальное ревью ветки — opus. Цена: слабее контекст сессии; страхуют пошаговые ревью и таблица находок. +136. **R-2 (план 1). Р-34 и Р-43 учтены в брифах**: подтверждение кода — флаг `code_fixed` (`is_confirmed`), уверенность не меняется; `arm-statements` внесён в таблицу workspace (`CLAUDE.md` workspace, `context/00-workspace-layout.md`, коммит e6eef27 ветки feat/116). Цена: нет. +137. **R-3 (план 1). `parse_code`: краевая `|` — как в спеке**: `|` → 1, если результат в кодовом диапазоне (строка 178 spec.md), допустимы 4–5 цифр: «1220|» → 12201, «|1220» → 11220 с `fixed=True` (подсвечивается). Не менялось: буквальное чтение спеки. Цена: код с краевой чертой OCR становится пятизначным с подсветкой; вопрос владельцу спеки (кандидат в follow-up). Ledger I-2 (T4). +138. **R-4 (план 1). Тест плана `test_sublines` исправлен по спеке**: тест плана (1230 = 10 против 4 + 5 = 9) противоречил допуску ±1 мягких соотношений (расхождение 1 — в допуске); оставлено поведение по спеке, тест заменён парой (4+4 → нарушение, 4+5 → нет). Цена: если для подстрок хотели строже — допуск подстрок 0 вместо ±1. +139. **R-5 (план 1). Неуверенный код вне перечня редакции не подтверждается структурой**: спека явного правила не даёт; по общему «подтверждён ⇔ входит во все лучшие подпоследовательности», а код вне перечня в последовательность не входит. Цена: неуверенный нестандартный код из OCR получает `needs_review` (безопасная сторона). +140. **R-6 (план 1). История ветки feat/1-arm-statements-package переписана `git filter-branch --msg-filter`**: один коммит имел трейлер «Claude Sonnet 5.5» вместо требуемого «Claude Opus 5.5»; ветка не публиковалась. Цена: SHA коммитов в ledger/отчётах до переписи устарели. +141. **R-7 (план 1). Решения исполнителей задач при пробелах спеки**: T6.2 (смешанные/повторные разделители суммы → цифры подряд ≤ 16 и `is_recognized=false`; excel допускает ≥ 3 знака после разделителя); T10.1–T10.4 (`SEGMENT_WITHOUT_CODES` только если столбец кодов найден хотя бы у одного сегмента; ослабленный поиск включается, когда строгий не нашёл и отрезок известен; сегмент двухстрочной повторной шапки открывается строкой с подписью кода; сопоставление по датам требует разных целевых колонок); T11.2 (сегмент без столбца кодов — наименование слева, значения по `value_cols` или правее наименования); T14.3–T14.4 (ничья без даты — `edition=None`, подтверждено в обеих; родитель пятизначного — ближайшая выше строка с нужным кодом, уверенная или подтверждённая); T15.2 («строгое большинство» знака = более 50 %). Ревью задач: со спекой не расходятся. Цена: перечисленные трактовки при пробелах спеки; пересмотр — правкой кода пакета и словарей. +142. **R-8 (план 1). Расширение публичного API сверх раздела плана**: в `__all__` добавлены `Rules` (нужен хостам в сигнатурах) и `RulesError` (обработка `load_rules`); абзац про них — в README пакета. Цена: хосты могут зависеть от двух лишних имён. + +**Решения плана 2 (sync-statements), 18.10.2026** (дорожка плана 2, ветка docs `feat/120-sync-statements`, arm-ka-docs#120; решения P2-n — автор плана, R-n — контроллер): + +143. **P2-1 (O-1 плана 1).** Файл конфига линтеров в копию не кладётся; «конфиг пакета подключён к хосту» — это: ruff хоста уже совпадает с пакетом (`line-length = 120`, `py311`, `E F I UP B PL`), в isort `known-first-party` добавлен `arm_statements`; в `mypy` хоста модули `arm_statements.*` входят в строгий блок (те же флаги, что `strict = true` пакета). Цена: блок флагов повторён в трёх хостах; если хост когда-нибудь станет строже пакета, копия покраснеет в хосте — чинить в `arm-statements`. +144. **P2-2 (O-2).** Пины `ruff==0.16.10`, `mypy==2.4.0`, `types-PyYAML==6.0.12.20260906` во всех хостах (у tabular-parser и arm-core были `>=`; в их окружениях уже стоят эти версии — пины ничего не переформатируют). `pytest` не закрепляется: тесты пакета не зависят от его версии. Цена: обновление ruff/mypy — согласованно, сначала пакет. +145. **P2-3 (O-3).** `testpaths = tests arm_statements/tests` в `pytest.ini` хостов; `conftest` хостов не меняется: у них нет `api/conftest.py`, а `tests/conftest.py` действует только на `tests/`; `env`, `asyncio_mode`, `--strict-markers` тестам пакета не мешают. Проверка — равное число собранных тестов пакета в хосте и в `arm-statements`. +146. **P2-4 (O-4).** Dockerfile хостов не меняется: `COPY api/ ./` уже кладёт `api/arm_statements/rules/*.yaml`, `.dockerignore` их не исключает; платформа уже возит `api/rules/*.yaml` и `api/contracts/` (сервисы читают их с диска в контуре). Проверка — запуск `default_rules()` в собранном образе (задачи 7–9). +147. **P2-5.** Подключение хостов — три bounded PR этого плана (задачи 7–9), а не части планов 3/4/5: правки одинаковые и механические, план 3 уже ждёт «PR плана 2 в arm-core», первая раскладка проверяет скрипт на настоящих хостах, а планы 4/5 стартуют от `main` с копией. Цена: ещё три PR и копия в `main` хостов до того, как её вызывает код сервиса. +148. **P2-6.** Режим `--in-place` (раскладка в существующий worktree без issue, коммита и PR) — для подключения хоста и для PR фичи, которой нужна новая версия пакета; спека дополнена (задача 6). Цена: второй путь раскладки, мимо отдельного PR синхронизации. +149. **P2-7.** Скрипт — bash по образцу `sync-web.sh` (spec называет `sync-statements.sh`), разбор TOML/YAML — помощник `statements_check.py` (Python 3.11 + PyYAML); интерпретатор — `STATEMENTS_PY` → `docs/.venv` → `api/.venv` хоста → `python3`; подходящего нет — код `2`. Цена: `make venv` docs ставит PyYAML (как и в PR #117). +150. **P2-8.** В `--check` отсутствие копии — `20` («копии нет»): хост, в котором вызывают проверку, обязан держать копию. В обычном режиме хост без копии — `2` с указанием на `--in-place`: подключение хоста — ручная задача с правкой конфигов. +151. **P2-9.** Нет `contracts/statement-code-orders.yaml` в docs — `10` (docs старше пакета; так до слияния PR #117). +152. **P2-10.** Зависимости сверяются по имени (PEP 503) с `api/requirements.txt` хоста (его ставит образ); разные версии — примечание, код не меняют. Цена: несовпадение версии PyYAML ловят только тесты пакета в хосте. +153. **P2-11.** «Копия отстала» — буквально по спеке: `VERSION` ≠ `--ref`, даже если новые коммиты источника не трогают `arm_statements/` (README, Makefile). Цена: лишние предупреждения `10` после таких коммитов (см. O-1). +154. **P2-12.** Ошибка `fetch` в `--check` не фатальна (предупреждение, сравнение с локальными ссылками): проверка перед PR не должна краснеть без сети, если копия на месте. +155. **P2-13.** Сравнение — по файлам рабочего дерева (`diff -rq`), без `VERSION` и кэшей (`__pycache__`, `*.pyc`, `.pytest_cache`, `.mypy_cache`, `.ruff_cache`, `.DS_Store`); биты режима не сравниваются. Незакоммиченная правка — тоже `20`. +156. **P2-14.** Обычный режим перед коммитом гоняет `make venv` и `make check` хоста в новом worktree (как `sync-web.sh` — проверки фронта). Цена: минута на окружение. +157. **P2-15.** «Сначала ядро» при синхронизации сервиса — предупреждение (в выводе и в описании PR), не отказ: порядок важен для смены формата результата, а не для каждой правки пакета. +158. **P2-16.** `make -C docs statements-check` — обзор по трём хостам рядом (`0`/`10` — зелёные); в `make check` docs не входит: читает соседние репозитории. +159. **P2-17.** Имя хоста — `--host` или последний компонент `origin` (`arm-core`, `arm-tabular-parser`, `arm-scan-gateway`), иначе имя каталога; сверка словаря имён — только у `arm-core`. +160. **P2-18.** Раскладка отказывает (код 1), если файлы копии игнорируются `.gitignore` хоста или содержат то, на чём остановится снимок для контура (`/Users/`, `/home/…`, `git.aepif.ru`, `uv venv`, `uv pip`). +161. **P2-19.** Строки `make venv` docs — дословно как в ветке `feat/116-unified-normalization`, строки таблицы `dev/checks.md`, которые меняет PR #117, не трогаются: два PR сливаются в любом порядке без конфликта. +162. **P2-20.** В `CLAUDE.md` каждого хоста — правило «копию не править, обновлять скриптом»: агенты читают его первым. Шаблоны `docs/repo-templates/` не меняются (разошлись с репозиториями давно). +163. **P2-21.** Ветка docs плана — от `origin/main` (шаг 1 выкладки раньше PR #117); спека, `design.md` и раздел «Копия» `dev/checks.md` правятся в `feat/116-unified-normalization` (задача 6). +164. **R-1 (план 2). `make check` в docs красный по `validate-openspec`**: в Makefile `CHANGE ?= slice-0-skeleton`, change в архиве (O-1 плана 0); план 2 это не чинит. Цели проверялись по отдельности (`validate-contracts`, `test-snapshot`, `test-sync-web`, `test-sync-statements` — зелёные), в PR красный `validate-openspec` назван. Цена: `make check` остаётся красным до отдельной правки Makefile. +165. **R-2 (план 2). Р-42 исполнен в этом плане (задача 5b)**: `prepare-contour-snapshot.sh` для каждого из трёх сервисов запускает `sync-statements.sh --check` по дереву `api` коммита `$REF` (`git archive` во временный каталог, `--host <имя>`): `0` — тихо; `10` — предупреждение и абзац в `SNAPSHOT.md`; `20` — стоп, код 1; `2` (нет источника или интерпретатора) — предупреждение, снимок делается; если в `$REF` нет `api/arm_statements` — проверка пропускается (пакет в хост ещё не подключён: иначе `20` «копии нет» блокировал бы снимок до задач 7–9). Цена: пока хост без копии, снимок о ней не напомнит; тест снимка требует Python ≥ 3.11 с PyYAML (`STATEMENTS_PY=$(PY)` в `test-snapshot`). +166. **R-3 (план 2). Спека: «и его конфигом линтеров» убрано**: предложение спеки о копии «вместе с … конфигом линтеров» противоречило P2-1 (конфиг пакета в копию не кладётся, применяется конфиг хоста); исправлена спека и раздел «Копия общего кода» `dev/checks.md`. Цена: нет. +167. **R-4 (план 2). Защиты раскладки липкие, копия при отказе защиты остаётся в дереве**: игнорируемые `.gitignore` файлы и пути контура (P2-18) проверяются и после раскладки, и при повторе `--in-place` с той же версией («раскладывать нечего» код 1, если копию нельзя коммитить); при коде 1 копия остаётся в дереве хоста, об этом сказано в сообщении. После `make check` хоста в обычном режиме `evaluate` повторяется (форматтеры могли переписать копию) — `20` блокирует коммит. Цена: после отказа защиты оператор сам убирает копию (`git checkout`/`clean`). +168. **R-5 (план 2). `--in-place` требует `--host-dir` и не работает на основном checkout и ветке main/master** (финальное ревью ветки docs, I-1): `--in-place --host <имя>` разворачивался в основной checkout на main и стирал там `api/arm_statements`; теперь код 2. Цена: `--in-place` только с `--host-dir` внутри worktree фичи. diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md new file mode 100644 index 0000000..3d981b9 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md @@ -0,0 +1,506 @@ +# План 0 — тест идентичности копий контракта на окне перехода v1 → v2. План реализации + +> **Для исполнителей-агентов:** обязательный навык — superpowers:subagent-driven-development или superpowers:executing-plans +> (в плане 5 задач: по `~/.claude/CLAUDE.md` — executing-plans, Native). Шаги размечены чекбоксами (`- [ ]`). + +**Goal:** тест идентичности `api/contracts/` в arm-core, tabular-parser и scan-gateway сверяет каждый файл копии с +файлом docs своего MAJOR (правило S-53), чтобы `make check` всех трёх оставался зелёным и до, и после слияния docs PR #117 +(контракт v2.0). + +**Architecture:** правило сравнения — один модуль `api/tests/contract_identity.py` (чистые функции над двумя каталогами, +только stdlib) и один файл сценарных тестов на синтетических каталогах; оба файла побайтно одинаковы в трёх +репозиториях (пакета для общего кода тестов нет, `arm-statements` ещё не существует). Существующий +`test_vendored_contracts_are_identical_to_docs` в каждом репозитории сохраняет поиск docs и пропуск, а вместо сравнения +`rglob`-наборов вызывает `identity_problems`. Шаг 0 выкладки (`proposal.md`, «Порядок выкладки»): три PR сливаются +до docs PR #117. + +**Tech Stack:** Python 3.11, pytest, ruff, mypy (`make check`), git worktree, `tea`, OpenSpec 1.13. + +**Spec:** `openspec/changes/unified-statement-normalization/` (репозиторий arm-ka-docs, ветка +`feat/116-unified-normalization`): `specs/recognition-contract/spec.md` — требование «Копия контракта в сервисе +идентична источнику» и его сценарии; `specs/quality-checks/spec.md` — `make check`; `proposal.md` — «Порядок выкладки», +шаг 0; `design.md` — решение S-53. Справка по проверкам — `dev/checks.md` («Внешние ресурсы», «Проверки этого +репозитория», «Известные расхождения реализаций»). + +## Global Constraints + +- Пути — абсолютные. Workspace: `/Users/andrey.epifancev/projects/awp-ca` (не git). Git-команды — только + `git -C <абсолютный путь worktree>`; cwd оболочки никогда не стоит в основном checkout на `main` (хук guard-main). +- Зеркальный workspace плана: `/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/` — worktree `arm-core/`, + `tabular-parser/`, `scan-gateway/` (ветки `feat/-contract-identity-window`), `docs/` — **отсоединённый** worktree + arm-ka-docs только для проверок (в нём ничего не коммитится), симлинки `fixtures`, `reference-old-arm`, `CLAUDE.md`, + `.claude` на основной workspace (`docs/context/00-workspace-layout.md`, «Worktree вместо веток»). +- Одна фича = одна issue = одна ветка = один PR на репозиторий; ветки `feat/-contract-identity-window`; Gitea — + `tea … --login home --repo RSHB/` (`RSHB/arm-core`, `RSHB/arm-tabular-parser`, `RSHB/arm-scan-gateway`). +- Правило сравнения — дословно из спеки `recognition-contract`: схема копии `<имя>.schema.json` с `$id` `…/v` — + с `docs/contracts/<имя>.v.schema.json`, **если такой файл есть**, иначе с основным `<имя>.schema.json`; пример + `examples/<имя>.json` — вид по префиксу (`request-` — запрос, `result-message-` — конверт, `result-` — файл результата), + `M` — MAJOR копии схемы этого вида, пара — `examples/v/<имя>.json`, если он есть, иначе `examples/<имя>.json`; прочие + файлы копии (`<имя>.v.schema.json`, `examples/v/*.json`) — с одноимённым файлом docs, нет его — расхождение; + набор: каждой схеме верхнего уровня docs (`contracts/*.schema.json` без `.v`) и каждому примеру + `contracts/examples/*.json` соответствует файл копии; файлы docs `*.v.schema.json` и `examples/v/` требуются в + копии, только если копия держит этот MAJOR; файлы другого MAJOR не сверяются. У `recognition-request` файла `.v1` нет. +- `$id` схем в docs имеют вид `arm-ka/<имя>/v` (`arm-ka/recognition-result/v1`, `…/v2`). +- Сверяются только `*.json` (как сейчас); `contracts/statement-code-orders.yaml` в копии сервисов не лежит. +- Поиск docs: каталог из `ARM_KA_DOCS`, иначе соседний `docs` рядом с репозиторием; нет каталога `contracts` — `pytest.skip` + с текстом `docs недоступен: тест идентичности контракта пропущен (задайте ARM_KA_DOCS или положите docs рядом)`. +- Файлы `api/tests/contract_identity.py` и сценарного теста (`api/tests/test_contract_identity.py` в arm-core, + `api/tests/unit/test_contract_identity.py` в tabular-parser и scan-gateway) побайтно одинаковы в трёх репозиториях; + импорт — `from tests.contract_identity import …` (работает во всех трёх, `rootdir` — `api/`). +- Линтер одинаков в трёх репозиториях: ruff `line-length = 120`, `select = ["E", "F", "I", "UP", "B", "PL"]`; `PLR2004` + игнорируется в тестах только у scan-gateway — сравнений с числовыми литералами в новых файлах нет. +- Копии `api/contracts/**` в этом плане не меняются (они побайтно равны docs main и `.v1`-файлам ветки + `feat/116-unified-normalization` — сверено 18.10.2026). +- `make check` не требует Docker, сети и запущенной инфраструктуры (`quality-checks`). +- Язык сообщений теста, комментариев, коммитов, issue и PR — русский. Коммиты заканчиваются строками + `Co-Authored-By: Claude Opus 5.5 ` и + `Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W`; PR — строкой + `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. +- Слияние — только по команде человека; три PR сливаются **до** docs PR #117. Описание PR #117 не трогать. +- Находки и решения несут маркеры (`T.`, `C-n`/`I-n`/`M-n`, `R-n`, `O-n`); решения этого плана — `P0-n` (ниже). + +### Проверка на двух состояниях docs + +Состояние **A** — docs `main` (контракт 1.x, файлов `.v1` нет); состояние **B** — ветка `feat/116-unified-normalization` +(основные схемы v2.0, `*.v1.schema.json`, `examples/v1/`). Переключается отсоединённый worktree +`/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/docs`, так что все три репозитория видят его как `../docs` +без переменных окружения, а правки других агентов в `.wt/normalization/docs` проверку не задевают: + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/docs fetch origin +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/docs checkout --detach origin/main # A +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/docs checkout --detach origin/feat/116-unified-normalization # B +``` + +После проверки B worktree всегда возвращается в A. Запасной путь для B без переключения — `ARM_KA_DOCS=<каталог> +make check` (arm-core и tabular-parser — сейчас, scan-gateway — после Task 3). Признак настоящей проверки, а не пропуска: +`cd api && .venv/bin/pytest -rs -k vendored_contracts <файл test_contract.py>` печатает `PASSED`, а не `SKIPPED`. + +### Решения плана, принятые без обсуждения (переносятся в `design.md` задачей 4) + +- **P0-1.** Правило живёт копией в трёх репозиториях (`api/tests/contract_identity.py` + сценарный тест), а не общим + пакетом и не скриптом docs. Эталон — копия arm-core (пишется первой); расхождение копий ловит шаг сверки `shasum` + задачи 5 и ревью; соседние репозитории в `make check` не читаются. Цена ошибки: правка правила — три PR; разошедшиеся + копии до сверки не видны. +- **P0-2.** «Копия держит MAJOR N» — в копии есть хотя бы один файл `*.v.schema.json` или `examples/v/*.json`. + Тогда все файлы docs `*.v.schema.json` и `examples/v/*.json` обязательны в копии. Цена: ядро, положившее один + файл `.v1`, обязано положить все. +- **P0-3.** Неопределимая пара — ошибка теста с именем файла, а не пропуск: пример с неизвестным префиксом, схема без + `$id` вида `…/v`, пример, вида которого нет среди схем копии. Цена: новый вид примера в docs требует правки правила. +- **P0-4.** scan-gateway получает поиск docs через `ARM_KA_DOCS` в тесте идентичности (спека требования называет + `ARM_KA_DOCS`); поиск `fixtures` в scan-gateway не меняется. Цена: расхождение в `dev/checks.md` сужается до fixtures. +- **P0-5.** tabular-parser и scan-gateway получают OpenSpec change `contract-identity-window` с MODIFIED требования + «Копия контракта идентична источнику» (их текущий текст — «байт в байт равны `../docs/contracts/`» и «модели валидируют + примеры docs» — на окне неверен). arm-core change не получает: его спеки тест идентичности не описывают, правило — + в спеке docs `recognition-contract`. Цена: у arm-core при закрытии нет `openspec archive`. + +## Review Focus + +- **Новый пример верхнего уровня в docs v2 без пары в `examples/v1/`** (например, отдельный пример OCR в PR #117): копия + сервиса v1 его не имеет — по правилу набора тест сервиса краснеет на окне, вопреки сценарию «посторонний PR не + краснеет» (O-1). Ожидание — сообщение называет файл; тест `test_new_top_level_docs_example_is_reported` (задача 1); + задача 5 повторяет проверку B на последнем коммите ветки перед слиянием #117. +- **docs удалил `.v1` раньше, чем ядро — свои копии** (нарушение порядка шага 6): тест ядра падает и называет файл + `.v1`; тест `test_window_copy_without_docs_pair_fails` (задача 1). +- **Схема копии без `$id` или с `$id` без `/v`**: падение с именем файла, а не исключение без контекста или молчаливое + сравнение с основным файлом; тест `test_schema_without_major_fails` (задача 1). +- **Префикс `result-message-` начинается с `result-`**: конверт и файл результата разных MAJOR не должны спутаться; + тест `test_example_kind_by_longest_prefix` (задача 1). +- **docs не найден или `ARM_KA_DOCS` указывает не туда**: пропуск с причиной, зелёный `make check` без ложной + «идентичности»; в scan-gateway до задачи 3 `ARM_KA_DOCS` игнорировался — тесты + `test_docs_contracts_dir_uses_arm_ka_docs` и `test_docs_contracts_dir_defaults_to_sibling_docs` (задача 3). + +--- + +### Task 1: arm-core — правило S-53 и сценарии (эталон копий) + +Модель исполнителя: sonnet (новый код). + +**Files:** +- Create: `/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/arm-core/api/tests/contract_identity.py` +- Create: `/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/arm-core/api/tests/test_contract_identity.py` +- Modify: `/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/arm-core/api/tests/test_contract.py:100-124` + (раздел «идентичность вендоренных копий»; `_docs_contracts_dir()` через `default_dbml_path()` остаётся) +- Modify: `/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/arm-core/CLAUDE.md:155-157` (строка о копиях + контракта) + +**Interfaces:** +- Consumes: — +- Produces (`tests/contract_identity.py`, используют задачи 2, 3 и план 3 ядра): + - `KIND_BY_PREFIX: tuple[tuple[str, str], ...] = (("request-", "recognition-request"), ("result-message-", + "recognition-result-message"), ("result-", "recognition-result"))` — порядок значим, первый подходящий префикс; + - `schema_major(schema_path: Path) -> int` — MAJOR из `$id` (`re` `/v(\d+)$`); `ValueError` с текстом + `"<имя файла>: в $id нет MAJOR (…/v)"`, если `$id` нет или он не того вида; + - `example_kind(file_name: str) -> str` — имя схемы вида; `ValueError` `": вид примера не определён по + префиксу (request-, result-message-, result-)"`; + - `docs_pair(copy_rel: str, copy_dir: Path, docs_dir: Path) -> str` — относительный POSIX-путь файла docs, с которым + сверяется файл копии `copy_rel`; `ValueError` с именем файла, если пара неопределима (в т. ч. «нет схемы + `<вид>.schema.json` в копии»); + - `held_window_majors(copy_rels: set[str]) -> set[int]` — MAJOR, которые копия держит файлами `.v` (P0-2); + - `identity_problems(copy_dir: Path, docs_dir: Path) -> list[str]` — все расхождения, каждое начинается с + относительного пути файла; пустой список — копия идентична. + - Тексты расхождений (на них опираются тесты): `": отличается от docs/contracts/ — скопируйте заново + (cp), не правьте руками"`; `": нет пары в docs/contracts ()"`; `": есть в docs/contracts, + нет в api/contracts"`; при `ValueError` от `docs_pair` — `": <текст исключения>"`. + +- [ ] **Step 1: Зеркальный workspace, issue, ветка, индекс.** cwd — `/Users/andrey.epifancev/projects/awp-ca`. + +```bash +WT=/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window +mkdir -p $WT && cd $WT +ln -s ../../fixtures fixtures; ln -s ../../reference-old-arm reference-old-arm; ln -s ../../CLAUDE.md CLAUDE.md; ln -s ../../.claude .claude +git -C /Users/andrey.epifancev/projects/awp-ca/docs fetch origin +git -C /Users/andrey.epifancev/projects/awp-ca/docs worktree add --detach $WT/docs origin/main +git -C /Users/andrey.epifancev/projects/awp-ca/arm-core fetch origin +git -C /Users/andrey.epifancev/projects/awp-ca/arm-core worktree add -b tmp/contract-window $WT/arm-core origin/main +tea issues create --login home --repo RSHB/arm-core \ + --title "Тест идентичности копии контракта по MAJOR (шаг 0 docs#116)" \ + --description "Правило S-53 спеки recognition-contract (docs change unified-statement-normalization): копия сверяется с файлом docs своего MAJOR, если он есть, иначе с основным. Шаг 0 выкладки: слить до docs PR #117. План: openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-0-contract-identity.md" +git -C $WT/arm-core branch -m feat/-contract-identity-window +cd $WT/arm-core && codegraph init && make venv +``` + +Expected: `git -C $WT/arm-core branch --show-current` → `feat/-contract-identity-window`; `ls $WT/docs/contracts` +без `*.v1.schema.json`. `` — номер созданной issue. + +- [ ] **Step 2: Красный базовый прогон текущего теста на состоянии B.** Переключить docs в B («Проверка на двух + состояниях docs»), затем: + +Run: `cd $WT/arm-core/api && .venv/bin/pytest tests/test_contract.py -k vendored_contracts -rs` +Expected: FAIL `набор файлов контракта разошёлся: … только в docs/contracts: ['examples/v1/result-error.json', …, +'recognition-result.v1.schema.json']`. Вернуть docs в A. + +- [ ] **Step 3: Сценарные тесты** в `api/tests/test_contract_identity.py` (синтетические каталоги в `tmp_path`, реальный + docs не нужен). Построители и наборы: + +```python +"""Правило теста идентичности копии контракта (S-53, спека recognition-contract). Файл одинаков в arm-core, +tabular-parser и scan-gateway; правка — сразу во всех трёх.""" + +import json +from pathlib import Path + +import pytest + +from tests.contract_identity import docs_pair, example_kind, identity_problems, schema_major + +REQ, MSG, RES = "recognition-request", "recognition-result-message", "recognition-result" + + +def _schema(name: str, major: int) -> str: + return json.dumps({"$id": f"arm-ka/{name}/v{major}"}) + + +def _example(major: int, mark: str) -> str: + return json.dumps({"schema_version": f"{major}.0", "mark": mark}) + + +def _v1_copy() -> dict[str, str]: # копия сервиса сегодня = docs main до #117 + return { + f"{REQ}.schema.json": _schema(REQ, 1), + f"{MSG}.schema.json": _schema(MSG, 1), + f"{RES}.schema.json": _schema(RES, 1), + "examples/request-image.json": _example(1, "request"), + "examples/result-message-done.json": _example(1, "message"), + "examples/result-statement-excel.json": _example(1, "result"), + } + + +def _v2_main() -> dict[str, str]: # копия сервиса после шага 5 = docs после #117 без файлов окна; запрос остаётся v1 + return { + f"{REQ}.schema.json": _schema(REQ, 1), + f"{MSG}.schema.json": _schema(MSG, 2), + f"{RES}.schema.json": _schema(RES, 2), + "examples/request-image.json": _example(1, "request"), + "examples/result-message-done.json": _example(2, "message"), + "examples/result-statement-excel.json": _example(2, "result"), + } + + +def _docs_window() -> dict[str, str]: # docs после #117 = копия ядра после шага 3 + v1 = _v1_copy() + return { + **_v2_main(), + f"{MSG}.v1.schema.json": v1[f"{MSG}.schema.json"], + f"{RES}.v1.schema.json": v1[f"{RES}.schema.json"], + "examples/v1/result-message-done.json": v1["examples/result-message-done.json"], + "examples/v1/result-statement-excel.json": v1["examples/result-statement-excel.json"], + } + + +def _dirs(tmp_path: Path, copy: dict[str, str], docs: dict[str, str]) -> tuple[Path, Path]: + """Пишет файлы в tmp_path/copy и tmp_path/docs (подкаталоги создаёт), возвращает (copy_dir, docs_dir).""" +``` + +Тесты и их утверждения (`c, d = _dirs(tmp_path, <копия>, )`): + +| Тест | Копия / docs | Утверждения | +|---|---|---| +| `test_step0_before_docs_merge_passes` | `_v1_copy()` / `_v1_copy()` | `identity_problems(c, d) == []`; `docs_pair(f"{RES}.schema.json", c, d) == f"{RES}.schema.json"` | +| `test_stale_copy_fails_naming_file` | `_v1_copy()` / `_v1_copy()` с `RES`-схемой `json.dumps({"$id": "arm-ka/recognition-result/v1", "new": 1})` | одно расхождение; начинается с `f"{RES}.schema.json"`, содержит `"отличается"` | +| `test_service_on_v1_after_v2_merge_passes` | `_v1_copy()` / `_docs_window()` | `[]`; пара `RES`-схемы — `f"{RES}.v1.schema.json"`, `MSG`-схемы — `f"{MSG}.v1.schema.json"` | +| `test_request_v1_pairs_with_main_after_v2_merge` | `_v1_copy()` / `_docs_window()` | пара `f"{REQ}.schema.json"` — она же; пара `"examples/request-image.json"` — она же | +| `test_previous_major_examples_pair_with_v1_dir` | `_v1_copy()` / `_docs_window()` | пары `examples/result-statement-excel.json` и `examples/result-message-done.json` — `examples/v1/<то же имя>` | +| `test_service_on_v2_passes_and_ignores_v1_files` | `_v2_main()` / `_docs_window()` | `[]`; пара `RES`-схемы — `f"{RES}.schema.json"`; пара `examples/result-statement-excel.json` — она же | +| `test_core_window_copy_passes` | `_docs_window()` / `_docs_window()` | `[]`; пары `f"{RES}.v1.schema.json"` и `examples/v1/result-statement-excel.json` — они же | +| `test_core_window_copy_requires_every_v1_docs_file` | `_docs_window()` без `examples/v1/result-message-done.json` / `_docs_window()` | расхождение `"examples/v1/result-message-done.json: есть в docs/contracts, нет в api/contracts"` | +| `test_window_copy_without_docs_pair_fails` | `_docs_window()` / `_v2_main()` (docs удалил `.v1` раньше ядра) | расхождения начинаются с `f"{RES}.v1.schema.json"` и `"examples/v1/result-statement-excel.json"`, содержат `"нет пары"` | +| `test_top_level_docs_file_missing_in_copy_fails` | `_v1_copy()` без `examples/request-image.json` / `_v1_copy()` | `"examples/request-image.json: есть в docs/contracts, нет в api/contracts"` в списке | +| `test_extra_copy_file_fails` | `_v1_copy()` + `examples/result-new.json` / `_v1_copy()` | расхождение начинается с `"examples/result-new.json"`, содержит `"нет пары"` | +| `test_new_top_level_docs_example_is_reported` | `_v1_copy()` / `_docs_window()` + `examples/result-statement-ocr.json` (v2, без пары в `v1/`) | ровно одно расхождение, начинается с `"examples/result-statement-ocr.json"` (O-1) | +| `test_unknown_example_prefix_fails` | `_v1_copy()` + `examples/foo.json` / то же | расхождение начинается с `"examples/foo.json"` и содержит `"вид примера"`; `pytest.raises(ValueError, match="вид примера")` для `example_kind("foo.json")` | +| `test_schema_without_major_fails` | `_v1_copy()` с `RES`-схемой `json.dumps({"$id": "arm-ka/recognition-result"})` / `_v1_copy()` | есть расхождение, начинающееся с `f"{RES}.schema.json"` и содержащее `"$id"`; `pytest.raises(ValueError)` у `schema_major` | +| `test_example_kind_by_longest_prefix` | копия: `_v1_copy()` с `RES`-схемой v2 и `examples/result-statement-excel.json` = `_example(2, "result")`; docs: та же копия + `examples/v1/result-message-done.json` = `_example(1, "message")` | `example_kind("result-message-done.json") == MSG`; пара `examples/result-message-done.json` — `examples/v1/result-message-done.json`; пара `examples/result-statement-excel.json` — она же | + +- [ ] **Step 4: Убедиться, что сценарии красные.** + +Run: `cd $WT/arm-core/api && .venv/bin/pytest tests/test_contract_identity.py -q` +Expected: ошибка сбора `ModuleNotFoundError: No module named 'tests.contract_identity'`. + +- [ ] **Step 5: Реализовать `api/tests/contract_identity.py`** (интерфейс — блок Produces). Шапка-докстринг: правило S-53, + ссылка на спеку `recognition-contract`, «файл побайтно одинаков в arm-core, tabular-parser и scan-gateway + (`api/tests/contract_identity.py`); правка — во всех трёх, сверка — `shasum -a 256`». Только stdlib (`json`, `re`, + `pathlib`). Алгоритм: + - `docs_pair`: путь вида `<имя>.v.schema.json` или `examples/v/<файл>.json` → он же; `<имя>.schema.json` в корне + → `M = schema_major(copy_dir / copy_rel)`, кандидат `<имя>.v.schema.json`, если `(docs_dir / кандидат).is_file()`, + иначе `copy_rel`; `examples/<файл>.json` (один уровень) → вид `example_kind(<файл>)`, схема вида + `copy_dir / f"{вид}.schema.json"` (нет — `ValueError`), `M = schema_major(…)`, кандидат `examples/v/<файл>`, если + он есть в docs, иначе `copy_rel`; любой другой путь → он же. + - `identity_problems`: наборы `rglob("*.json")` обоих каталогов (относительные POSIX-пути); для каждого файла копии по + порядку — пара (`ValueError` → `": <текст>"` в список, файл дальше не сверяется), пары нет в docs → «нет пары», байты различаются → «отличается»; + затем обязательный набор docs: схемы корня без `.v`, `examples/*.json` одного уровня, для каждого `N` из + `held_window_majors(<набор копии>)` — `*.v.schema.json` и `examples/v/*.json`; каждого нет в копии — + «есть в docs/contracts, нет в api/contracts». Порядок списка — по пути (детерминированный вывод). + +- [ ] **Step 6: Сценарии зелёные.** + +Run: `cd $WT/arm-core/api && .venv/bin/pytest tests/test_contract_identity.py -q` +Expected: `15 passed`. + +- [ ] **Step 7: Перевести реальный тест на правило.** В `tests/test_contract.py` тело + `test_vendored_contracts_are_identical_to_docs` после проверки `docs_dir.is_dir()` (пропуск и его текст — без + изменений) — `problems = identity_problems(CONTRACTS_DIR, docs_dir)`; `assert problems == [], "\n".join(problems)`. + Докстринг модуля: «вендоренные схемы `api/contracts/` идентичны `../docs/contracts/` по правилу MAJOR (S-53)». + `CLAUDE.md:155-157`: дописать, что копия сверяется с файлом docs своего MAJOR (`*.v.schema.json`, `examples/v/`, + если есть), правило — `tests/contract_identity.py`, одинаковый файл в трёх репозиториях. + +- [ ] **Step 8: `make check` на A и на B.** + +Run (A): `cd $WT/arm-core && make check`, затем `cd api && .venv/bin/pytest tests/test_contract.py -k vendored_contracts -rs` +Expected: `make check` — код 0; тест — `PASSED`. +Run (B): переключить docs в B, те же две команды, вернуть docs в A. +Expected: код 0; `PASSED` (копия v1 сверилась с `.v1` и `examples/v1/`). + +- [ ] **Step 9: Commit** + +```bash +git -C $WT/arm-core add api/tests/contract_identity.py api/tests/test_contract_identity.py api/tests/test_contract.py CLAUDE.md +git -C $WT/arm-core commit -m "test(#): тест идентичности копии контракта по MAJOR (S-53, шаг 0 docs#116)" \ + -m "Co-Authored-By: Claude Opus 5.5 +Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" +``` + +--- + +### Task 2: tabular-parser — копия правила, реальный тест, OpenSpec change + +Модель исполнителя: haiku (код готов в задаче 1, текст спеки — в плане). + +**Files:** +- Create: `/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/tabular-parser/api/tests/contract_identity.py` + (побайтная копия файла arm-core из задачи 1) +- Create: `/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/tabular-parser/api/tests/unit/test_contract_identity.py` + (побайтная копия `arm-core/api/tests/test_contract_identity.py`) +- Modify: `/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/tabular-parser/api/tests/unit/test_contract.py:104-128` +- Modify: `/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/tabular-parser/CLAUDE.md:45-46,92` +- Create: `/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/tabular-parser/openspec/changes/contract-identity-window/{proposal.md,tasks.md,specs/tabular-recognition-service/spec.md}` + +**Interfaces:** +- Consumes: `identity_problems(copy_dir: Path, docs_dir: Path) -> list[str]` и остальное из задачи 1 (файл копируется, не + переписывается). +- Produces: — + +- [ ] **Step 1: Worktree, issue, ветка, индекс** — как задача 1, шаг 1, для `tabular-parser` (`--repo + RSHB/arm-tabular-parser`, тот же заголовок и описание; симлинки и docs уже есть). +- [ ] **Step 2: Красный базовый прогон на B:** `cd $WT/tabular-parser/api && .venv/bin/pytest + tests/unit/test_contract.py -k vendored_contracts -rs` → FAIL «набор файлов контракта разошёлся … только в + docs/contracts: ['examples/v1/…']». Вернуть A. +- [ ] **Step 3: Скопировать оба файла** `cp` из `$WT/arm-core/api/tests/` (сценарный тест — в `tests/unit/`); проверить + `shasum -a 256` — суммы равны суммам arm-core. +- [ ] **Step 4: Сценарии зелёные:** `.venv/bin/pytest tests/unit/test_contract_identity.py -q` → `15 passed`. +- [ ] **Step 5: Реальный тест** — как задача 1, шаг 7 (`_docs_contracts_dir()` с `ARM_KA_DOCS` остаётся). + `CLAUDE.md`: в строках о копиях (`45-46`, `92`) — «идентичность по правилу MAJOR (S-53), `tests/contract_identity.py`». +- [ ] **Step 6: OpenSpec change `contract-identity-window`.** `proposal.md` — Why (окно перехода v1 → v2, шаг 0 + `proposal.md` docs change `unified-statement-normalization`), What Changes, Non-goals (копии `api/contracts/` и модели + не меняются; переход на v2.0 — шаг 5, отдельный change), Impact (`api/tests/`). `tasks.md` — один пункт со ссылкой на + этот план. Дельта `specs/tabular-recognition-service/spec.md`: + +```markdown +## MODIFIED Requirements + +### Requirement: Копия контракта идентична источнику +Каждый файл `api/contracts/**/*.json` SHALL быть байт в байт равен своей паре в `../docs/contracts/` (или в `contracts` +каталога из `ARM_KA_DOCS`) по правилу спеки docs `recognition-contract`, требование «Копия контракта в сервисе +идентична источнику»: схема с `$id` `…/v` — с `<имя>.v.schema.json`, если такой файл есть в docs, иначе с +основным; пример — с `examples/v/<имя>.json`, если он есть, иначе с `examples/<имя>.json`; файлы docs другого +`MAJOR` не сверяются. pydantic-модели SHALL валидировать все примеры копии `api/contracts/examples/*.json` и порождать +JSON, проходящий схему копии. Проверка входит в `make check`. + +#### Scenario: `make check` +- **WHEN** выполняются тесты контракта при доступном `../docs` (или `ARM_KA_DOCS`) +- **THEN** идентичность подтверждена, примеры валидны; при недоступном docs тест идентичности пропущен с предупреждением + +#### Scenario: Окно перехода на контракт 2.0 +- **WHEN** в docs основной `recognition-result.schema.json` уже v2.0, рядом `recognition-result.v1.schema.json` и + `examples/v1/`, а копия сервиса — v1.2 +- **THEN** копия сверяется с файлами `.v1` и `examples/v1/` (запрос — с основным файлом), тест проходит + +#### Scenario: Копия устарела +- **WHEN** в docs изменён файл, с которым сверяется копия, а копия — нет +- **THEN** тест идентичности падает, сообщение называет файл +``` + +Run: `cd $WT/tabular-parser && openspec validate contract-identity-window --strict` +Expected: `Change 'contract-identity-window' is valid`. + +- [ ] **Step 7: `make check` на A и на B** — как задача 1, шаг 8 (файл `tests/unit/test_contract.py`). Expected: код 0 + и `PASSED` в обоих состояниях. +- [ ] **Step 8: Commit** — как задача 1, шаг 9 (файлы задачи; сообщение `test(#): …`). + +--- + +### Task 3: scan-gateway — `ARM_KA_DOCS`, копия правила, реальный тест, OpenSpec change + +Модель исполнителя: haiku (новый код — три строки поиска docs, его тесты даны). + +**Files:** +- Create: `/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/scan-gateway/api/tests/contract_identity.py` + (побайтная копия) +- Create: `/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/scan-gateway/api/tests/unit/test_contract_identity.py` + (побайтная копия) +- Modify: `/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/scan-gateway/api/tests/unit/test_contract.py:113-134` +- Modify: `/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/scan-gateway/CLAUDE.md:87-88` +- Create: `/Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/scan-gateway/openspec/changes/contract-identity-window/{proposal.md,tasks.md,specs/image-recognition-gateway/spec.md}` + +**Interfaces:** +- Consumes: файлы задачи 1. +- Produces: `_docs_contracts_dir() -> Path` в `tests/unit/test_contract.py` — `Path(os.environ["ARM_KA_DOCS"]) / + "contracts"`, если переменная задана и непуста, иначе `API_DIR.parent.parent / "docs" / "contracts"` (как в + tabular-parser). + +- [ ] **Step 1: Worktree, issue, ветка, индекс** — как задача 1, шаг 1 (`--repo RSHB/arm-scan-gateway`). +- [ ] **Step 2: Красный базовый прогон на B:** `cd $WT/scan-gateway/api && .venv/bin/pytest tests/unit/test_contract.py + -k vendored_contracts -rs` → FAIL «набор файлов контракта разошёлся …». Вернуть A. +- [ ] **Step 3: Тесты поиска docs** в `tests/unit/test_contract.py`: + +```python +def test_docs_contracts_dir_uses_arm_ka_docs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.setenv("ARM_KA_DOCS", str(tmp_path)) + assert _docs_contracts_dir() == tmp_path / "contracts" + + +def test_docs_contracts_dir_defaults_to_sibling_docs(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("ARM_KA_DOCS", raising=False) + assert _docs_contracts_dir() == API_DIR.parent.parent / "docs" / "contracts" +``` + +Run: `.venv/bin/pytest tests/unit/test_contract.py -k docs_contracts_dir -q` +Expected: `test_docs_contracts_dir_uses_arm_ka_docs` FAIL (возвращается соседний `docs`). + +- [ ] **Step 4: Поиск docs через `ARM_KA_DOCS`** (`import os`; интерфейс — Produces); текст пропуска — общий из Global + Constraints (с «задайте ARM_KA_DOCS»). Run тот же → `2 passed`. +- [ ] **Step 5: Скопировать оба файла правила, сверить `shasum`, сценарии зелёные** — как задача 2, шаги 3–4. +- [ ] **Step 6: Реальный тест** — как задача 1, шаг 7. `CLAUDE.md:87-88`: «идентичность по правилу MAJOR (S-53, + `tests/contract_identity.py`); docs — `../docs` или `ARM_KA_DOCS`». +- [ ] **Step 7: OpenSpec change `contract-identity-window`** — как задача 2, шаг 6, дельта + `specs/image-recognition-gateway/spec.md` с тем же текстом требования и сценариями; в сценарии `make check` — «при + доступном `../docs` (или `ARM_KA_DOCS`)» и «при недоступном docs тест идентичности пропущен с предупреждением». + Run: `cd $WT/scan-gateway && openspec validate contract-identity-window --strict` → valid. +- [ ] **Step 8: `make check` на A и на B** — как задача 1, шаг 8; дополнительно B через переменную без переключения: + `cd $WT/scan-gateway && ARM_KA_DOCS=/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs make check` → код 0, + тест идентичности `PASSED`. +- [ ] **Step 9: Commit** — как задача 1, шаг 9. + +--- + +### Task 4: docs — `dev/checks.md` и решения P0-n в `design.md` + +Модель исполнителя: haiku. Репозиторий — worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs`, +ветка `feat/116-unified-normalization` (входит в PR #117; описание PR не трогать). + +**Files:** +- Modify: `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/dev/checks.md` — «Известные расхождения + реализаций», пункт `ARM_KA_DOCS`/`ARM_KA_FIXTURES`; абзац о правиле теста идентичности в «Проверки этого репозитория» +- Modify: `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/design.md` + — конец раздела `## Decisions made without discussion` + +**Interfaces:** +- Consumes: решения P0-1…P0-5 (раздел «Решения плана»), результат задачи 3 (scan-gateway читает `ARM_KA_DOCS` в тесте + идентичности). +- Produces: — + +- [ ] **Step 1: `dev/checks.md`.** В пункте `ARM_KA_DOCS`/`ARM_KA_FIXTURES`: scan-gateway учитывает `ARM_KA_DOCS` в тесте + идентичности контракта (с шага 0 #116), `ARM_KA_FIXTURES` — нет; фраза «docs/fixtures нужно класть рядом» — только + про fixtures. В абзаце о правиле: «правило реализовано файлом `api/tests/contract_identity.py`, побайтно одинаковым в + трёх репозиториях; правка — сразу во всех трёх». +- [ ] **Step 2: `design.md`.** Подраздел `### Решения при планировании (18.10.2026)` в конце + `## Decisions made without discussion`; P0-1…P0-5 — следующими свободными номерами списка (на 18.10.2026 последний — + 97, S-74; перед записью перечитать — параллельные планы дописывают тот же раздел), маркер `P0-n` в тексте сохраняется, + формат как у S-n: дата, решение, обоснование, цена ошибки. +- [ ] **Step 3: Проверка:** `cd /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs && make check` → код 0. +- [ ] **Step 4: Commit и push** (`git -C … pull --rebase` перед push; при запрете хука — `git pull` слиянием): + +```bash +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs add dev/checks.md openspec/changes/unified-statement-normalization/design.md +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs commit -m "docs(#116): тест идентичности копии контракта — ARM_KA_DOCS в scan-gateway, решения плана 0 (P0-1…P0-5)" -m "Co-Authored-By: Claude Opus 5.5 +Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs pull --rebase +git -C /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs push +``` + +--- + +### Task 5: сверка трёх копий и итоговая проверка на A и B + +Модель исполнителя: haiku. Кода не пишет; результат — таблица для описаний трёх PR. + +**Files:** — (только чтение и прогоны) + +**Interfaces:** +- Consumes: ветки задач 1–3. +- Produces: таблица «репозиторий × состояние docs → код `make check`, число тестов, пропуски» для PR. + +- [ ] **Step 1: Копии правила совпадают.** + +```bash +shasum -a 256 /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/{arm-core,tabular-parser,scan-gateway}/api/tests/contract_identity.py +shasum -a 256 /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/arm-core/api/tests/test_contract_identity.py \ + /Users/andrey.epifancev/projects/awp-ca/.wt/contract-window/{tabular-parser,scan-gateway}/api/tests/unit/test_contract_identity.py +``` + +Expected: в каждой из двух групп три одинаковые суммы. + +- [ ] **Step 2: A — docs main.** `make check` в трёх worktree; тест идентичности в каждом — `PASSED`, не `SKIPPED`. + Expected: три кода 0. +- [ ] **Step 3: B — последний коммит `origin/feat/116-unified-normalization`** (после `fetch`). То же. Expected: три кода 0. + Красный тест с расхождением «есть в docs/contracts, нет в api/contracts» по новому примеру верхнего уровня — это O-1: + остановиться и поднять владельцу, правило не ослаблять. Вернуть docs в A. +- [ ] **Step 4: Записать таблицу** в отчёт исполнителя; дальше — superpowers:finishing-a-development-branch: три PR + (`tea pulls create --login home --repo RSHB/`), в каждом — «Closes #», ссылка на docs#116 и этот план, + «**слить до docs PR #117** (шаг 0 выкладки)», таблица шага 2–3, список рулингов. Перед слиянием #117 шаг 3 повторяется + на его последнем коммите. + +--- + +## Самопроверка плана + +- Покрытие спеки: правило схем, примеров, прочих файлов и набора — задача 1 (шаг 5) и сценарии шага 3; сценарии + требования «Копия устарела», «Сервис ещё пишет 1.x», «Сервис перешёл на 2.0», «Запрос v1.0 после слияния v2.0», + «Примеры прежнего MAJOR», «Шаг 0 до слияния docs», «Ядро на окне перехода» — тесты таблицы задачи 1; `../docs` или + `ARM_KA_DOCS`, пропуск с предупреждением — задачи 1–3 (scan-gateway — задача 3); «всех трёх потребителей до слияния + v2.0» — задачи 1–3, порядок — задача 5, шаг 4; удаление MAJOR 1 «сначала ядро» — `test_window_copy_without_docs_pair_fails`; + `quality-checks` — `make check` задач 1–3, 5; `dev/checks.md` — задача 4. +- Интерфейсы: имена `identity_problems`, `docs_pair`, `example_kind`, `schema_major`, `held_window_majors`, + `KIND_BY_PREFIX` одинаковы в задачах 1–3 и в Review Focus; сценарный тест импортирует только `docs_pair`, + `example_kind`, `identity_problems`, `schema_major`. +- Счёт тестов сценариев — 15 (таблица задачи 1), он же в ожиданиях шагов 6 (задача 1) и 4 (задача 2). diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-1-arm-statements.md b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-1-arm-statements.md new file mode 100644 index 0000000..c31f00e --- /dev/null +++ b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-1-arm-statements.md @@ -0,0 +1,3366 @@ +# План 1 — пакет общего кода `arm_statements` (репозиторий `arm-statements`). План реализации + +> **Для исполнителей-агентов:** обязательный навык — superpowers:subagent-driven-development (в плане 20 задач: по +> `~/.claude/CLAUDE.md` — SDD, контроллер — фоновый сабагент на модели sonnet; исполнители задач — sonnet: готового кода +> в плане нет, есть сигнатуры, тесты и правила спеки; финальное ревью ветки — opus). Шаги размечены чекбоксами (`- [ ]`). + +**Goal:** новый репозиторий `arm-statements` с пакетом `arm_statements/` — общие шаги 2–8 нормализации ББ/ОФР, типы +шага 1, общие разборы текста, словари и точка входа пересчёта, с тестами внутри пакета и зелёным `make check`; публичный +API пакета зафиксирован в конце плана для планов 2–5. + +**Architecture:** пакет — чистые функции над frozen-dataclass-ами (stdlib, без pydantic): адаптер хоста строит +`FormCandidate`, `normalize_candidate` проводит его через шаги 2–8 и возвращает `StatementDocument` или предупреждения +файла; ядро пересчитывает сохранённую форму функцией `recompute(StoredForm)`. Шаги 6–8 у обоих путей идут через один +внутренний вид строк (`EvalLine`), поэтому правила §7, покрытия, знака, структуры и статуса у сервиса и ядра — один код. +Словари — YAML в `arm_statements/rules/`, загрузчик `load_rules` проверяет их при загрузке. Тесты пакета — +`arm_statements/tests/` (копируются в хосты вместе с пакетом), только синтетика и обезличенные копии. + +**Tech Stack:** Python 3.11, PyYAML 6.0.3 (единственная зависимость), pytest 9.1.1, ruff 0.16.10, mypy 2.4.0, uv, +GNU make, Gitea (`tea`), git worktree. + +**Spec:** `openspec/changes/unified-statement-normalization/` (репозиторий arm-ka-docs, ветка +`feat/116-unified-normalization`): главное — `specs/statement-normalization/spec.md` (все требования); что пакет отдаёт +хостам — `specs/recognition-contract/spec.md` («Файл результата»); перечни кодов — `docs/contracts/statement-code-orders.yaml`; +форма результата — `docs/contracts/recognition-result.schema.json` v2.0; `docs/context/05-domain-rules.md` §7 +(«Самопроверка в сервисах») и §11 (§11.3–§11.9); `proposal.md`; `design.md` — справка (Р-n, S-n), спека важнее. + +## Предусловия (для человека или контроллера, до задачи 1) + +- **П-1.** Человек создаёт в Gitea пустой репозиторий `RSHB/arm-statements` (ветка по умолчанию `main`). Агент + репозитории не создаёт. +- **П-2.** Клон — `/Users/andrey.epifancev/projects/awp-ca/arm-statements` (рядом с остальными репозиториями + workspace; `sync-statements` плана 2 ищет источник именно там). Обвязка подключается + `~/.claude/harness/adopt-project.sh` — единственная разрешённая запись в `main`; после неё в `main` есть хотя бы один + коммит (иначе worktree не создать). +- **П-3.** Контроллер: issue в `RSHB/arm-statements` (`tea issues create --login home --repo RSHB/arm-statements`, + заголовок «Пакет общего кода нормализации ББ/ОФР (#116 docs)», ссылка на RSHB/arm-ka-docs#116 и этот план); worktree + `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/arm-statements` на ветке + `feat/-arm-statements-package`; в worktree — `codegraph init`; в зеркальном workspace `.wt/normalization/` + симлинки `fixtures`, `reference-old-arm`, `CLAUDE.md`, `.claude` (как в `00-workspace-layout.md`). +- **П-4.** В тексте плана `$R` = `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/arm-statements`; все + команды — из `$R`, git — `git -C "$R"`. +- **П-5.** Перечень кодов копируется байтово из `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/contracts/statement-code-orders.yaml` + (ветка docs `feat/116-unified-normalization`, до слияния docs PR #117 в `main` docs его нет). Если файл в ветке docs + поменяется до слияния PR этого плана — копия обновляется тем же PR. + +## Global Constraints + +- Python 3.11; типы — `@dataclass(frozen=True, slots=True)` и `enum.StrEnum` из stdlib; **pydantic не используется**. + Единственная зависимость рантайма — `PyYAML==6.0.3` (есть в зеркале банка), список — `[project].dependencies` в + `pyproject.toml` (его читает `sync-statements --check`). +- Деньги — `decimal.Decimal`, квантование `Decimal("0.01")` с `ROUND_HALF_UP`; `float` в суммах запрещён. Уверенность — + `float` 0–100. Даты — `datetime.date`. +- Соответствия «код → наименование» в пакете нет нигде — ни в словарях, ни в коде (Р-3). +- `arm_statements/rules/statement_code_orders.yaml` — байтовая копия `docs/contracts/statement-code-orders.yaml`; руками + не правится. +- Тесты пакета не читают ничего вне `arm_statements/` (ни соседние репозитории, ни `fixtures/`, ни корень репозитория: + в хосте корнем будет `api/` хоста); реальные названия организаций, ИНН и адреса в тестах запрещены — только + обезличенные («ООО «Пример»», ИНН `7700000000`). +- Код и тесты пакета проходят самый строгий конфиг хостов **без исключений по файлам**: ruff `line-length = 120`, + `target-version = "py311"`, `select = ["E", "F", "I", "UP", "B", "PL"]`, без `per-file-ignores`; `ruff format`; mypy + `strict = true` (надмножество строгих флагов scan-gateway и arm-core). `PLR2004` в тестах не отключается: числа + сравнивать кортежами/списками (`assert cols == (2, 4, 5)`), через `D("…")` или именованные константы модуля; + `# noqa` — только с причиной в той же строке; `# type: ignore` запрещён (`warn_unused_ignores`). +- Код тестов в плане фиксирует входы, значения и утверждения; аннотации типов вспомогательных функций тестов, + которых требует mypy strict, исполнитель дописывает сам (поведение теста при этом не меняется). Если утверждение + теста противоречит спеке — источник истины спека: исполнитель отмечает расхождение в отчёте, а не подгоняет код. +- Тесты: `arm_statements/tests/` с `__init__.py` (имена модулей не конфликтуют с тестами хоста) и своим `conftest.py` + без `autouse`; без своих маркеров pytest (хосты запускают `--strict-markers`), без asyncio. +- Импорты внутри пакета — абсолютные `from arm_statements.<модуль> import …`; пакет не импортирует ничего из хоста + (`src.*`). Словари читаются по `Path(__file__).parent / "rules"`. +- Файла `arm_statements/VERSION` в репозитории нет (его пишет только `sync-statements` в копии хоста); путь — в + `.gitignore`. +- Код предупреждения и его уровень берутся только из словаря `rules/statement_warnings.yaml` (таблица спеки «Шаг 8 — + предупреждения»); тексты `message` — по-русски, свободные, кроме оговорённого в задачах. +- Детерминизм: одинаковый вход — одинаковый выход (без времени, случайности и зависимости от порядка `set`). +- Публичный API — только имена из `arm_statements/__init__.py` `__all__` (раздел «Публичный API пакета»); остальные + модули доступны тестам пакета, но хосты их не импортируют. +- Язык комментариев, docstring, сообщений предупреждений, коммитов — русский; идентификаторы — английские. +- Коммиты заканчиваются строками `Co-Authored-By: Claude Opus 5.5 ` и + `Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W`; PR — строкой + `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. Слияние — только по команде человека. +- Находки и решения несут маркеры (`T.`, `C-n`/`I-n`/`M-n`, `R-n`, `O-n`); решения этого плана — `P1-n` (раздел в + конце), контроллер переносит их в `design.md` docs (`## Decisions made without discussion`). + +## Review Focus + +Входы с реальных файлов (fixtures aep_3, НПЗ, выгрузки 1С), которые сценарии спеки описывают по одному, а встретятся +вместе. Каждый пункт закреплён тестом задачи 20 на обезличенной копии внутри пакета (`arm_statements/tests/golden/`): + +1. **Мусорные коды aep_3 в одной таблице ББ** — «НИ», «изо :», «| 140» среди нормальных кодов: «| 140» → строка `ok` + 1140 с неуверенным кодом, «НИ» и «изо :» → строки `detail` с `parent_ref` ближайшей `ok`, ни одна строка не пропала, + `row_index` сквозной и `ref` уникальны. +2. **Грязные суммы ОФР в одной колонке** — «(7744536}», «211(1 |», «42831 1» вместе со скобочными 2210/2220: + −7744536 со знаком «скобки» подтверждён голосованием, «211(1 |» → `amount = null`, `needs_review = true`, 428311 + покрыт сошедшимся соотношением; документ `needs_check` (держит `null`-ячейка). +3. **Склейка «1500\n1700» посреди ББ** — две строки `row:i#1`, `row:i#2`, суммы 917775 и 2371333, `ROW_SPLIT` (`info`), + соседние `ref` не пересекаются; при сошедшихся `1500 = …` и `1700 = …` подсветки нет. +4. **Титульный блок «Коды» выгрузки 1С** — под «Коды» ОКУД, дата, ОКПО, ИНН, ОКОПФ 12267, ОКФС, ОКЕИ, а у таблицы подписи + «Код» нет: столбец кодов — по правилу (б), строк с кодом 12267 нет (титульный блок — роль `title`), ББ `done`. +5. **Лист-продолжение НПЗ с одной строкой 2400 в другой колонке** — ОФР на двух листах, у второго нет шапки, код 2400 + стоит левее, чем на первом листе: ослабленный поиск столбца, значения 2400 под колонками первого листа без сдвига, + `locator` из двух листов, предупреждений `SEGMENT_*` нет, ОФР `done`. + +--- + +## Структура репозитория + +``` +arm-statements/ +├── pyproject.toml # [project] (имя, зависимости), [project.optional-dependencies].dev, ruff, mypy, pytest +├── Makefile # venv, lint, test, check, golden-update +├── README.md # назначение, раскладка копией, публичный API, как менять словари +├── .gitignore # Python + arm_statements/VERSION + .codegraph/ .superpowers/ .bounded +└── arm_statements/ + ├── __init__.py # публичный API (__all__) + ├── version.py # statements_version() + ├── model.py # перечисления и dataclass-ы входа и выхода + ├── serialize.py # to_jsonable(), candidate_from_dict() + ├── loader.py # Rules, load_rules(), default_rules(), RulesError + ├── issues.py # WarningCode, make_warning() + ├── text.py # буквы-двойники, normalize_caption / normalize_label, «цифра под видом буквы» + ├── codes.py # разбор кода, отрезки кодов, присоединение листа + ├── dates.py # parse_date, подпись колонки → период, ожидаемые даты + ├── amounts.py # parse_amount, build_value (шаг 4, ячейка) + ├── header.py # разбор шапки, type_hint, тип маркера продолжения + ├── layout.py # шаг 2: столбец кодов, нумерация, строка заголовка, роли, наименование + ├── columns.py # шаг 2: колонки значений, подписи, даты, номер колонки + ├── segments.py # шаг 2: сегменты, сопоставление колонок, find_layout() + ├── rows.py # шаг 3: строки данных, статусы, разлепление + ├── lines.py # шаги 4–5: StatementLine, предупреждения единиц + ├── evaluation.py # EvalLine / EvalValue — общий вид строк для шагов 6–8 + ├── checks.py # шаг 7: §7, вариант формы + ├── structure.py # шаг 8: перечни кодов, choose_edition(), подтверждение кода + ├── review.py # шаг 8: needs_review значений и строк, поля шапки, evaluate() + ├── status.py # функция статуса документа и конверта, status_input(документ) + ├── pipeline.py # шаг 6 и normalize_candidate() / normalize() + ├── recompute.py # StoredForm → RecomputeResult + ├── contract.py # to_contract(), warning_to_contract(), status_input_from_contract() — форма контракта v2.0 + ├── rules/ + │ ├── statement_checks.yaml # §7: полный и упрощённый наборы + │ ├── statement_periods.yaml # подписи периодов: относительные, текстовые, числовые + │ ├── statement_headers.yaml # заголовки форм, ОКУД, маркер продолжения, подписи колонок, метки шапки Excel + │ ├── statement_units.yaml # единицы: написания и ОКЕИ + │ ├── statement_code_ranges.yaml # отрезки кодов, пороги столбца кодов + │ ├── statement_code_orders.yaml # байтовая копия docs/contracts/statement-code-orders.yaml + │ ├── statement_warnings.yaml # код предупреждения → уровень и область + │ └── ocr_lookalikes.yaml # буквы-двойники + └── tests/ + ├── __init__.py + ├── conftest.py # фикстура rules (default_rules()) + ├── builders.py # ocr(), excel(), hdr(), place() — сборка FormCandidate для тестов + ├── golden/ # обезличенные кандидаты *.candidate.json и эталоны *.expected.json + └── test_*.py +``` + +Доноры логики (переносить правило, а не код целиком; смотреть через `codegraph_explore` с `projectPath` +`/Users/andrey.epifancev/projects/awp-ca/`): tabular-parser `api/src/parser/statements/{columns,lines,checks, +number_format,detect,merge}.py`, `api/src/parser/numbers.py`, `api/rules/statement_*.yaml`; scan-gateway +`api/src/normalizer/ocr/{text,dates,amounts,rows,layout}.py`, `api/rules/ocr_lookalikes.yaml`, +`api/rules/statement_labels.yaml`, `api/tests/unit/normalizer/test_dates.py`; arm-core +`api/src/domain/statement_checks.py` (`subline_parents`, `run_checks`). + +--- + +### Task 1: Каркас репозитория и `make check` + +**Files:** +- Create: `$R/pyproject.toml`, `$R/Makefile`, `$R/README.md`, `$R/.gitignore` +- Create: `$R/arm_statements/__init__.py`, `$R/arm_statements/version.py` +- Create: `$R/arm_statements/tests/__init__.py`, `$R/arm_statements/tests/conftest.py` +- Test: `$R/arm_statements/tests/test_version.py` + +**Interfaces:** +- Consumes: — +- Produces: `statements_version(path: Path | None = None) -> str | None` в `arm_statements/version.py` (по умолчанию + `Path(__file__).parent / "VERSION"`; содержимое без пробелов по краям; нет файла или пусто — `None`); цели Makefile + `venv`, `lint`, `test`, `check`, `golden-update`. + +- [ ] **Step 1: Write the failing test** + +```python +from pathlib import Path + +from arm_statements.version import statements_version + +FULL_HASH = "abc1234def5678abc1234def5678abc1234def56" + + +def test_version_absent_in_source() -> None: + assert statements_version() is None + + +def test_version_read_from_file(tmp_path: Path) -> None: + marker = tmp_path / "VERSION" + marker.write_text(f"{FULL_HASH}\n", encoding="utf-8") + assert statements_version(marker) == FULL_HASH + + +def test_version_empty_file_is_none(tmp_path: Path) -> None: + marker = tmp_path / "VERSION" + marker.write_text(" \n", encoding="utf-8") + assert statements_version(marker) is None +``` + +- [ ] **Step 2: Создать конфиг и Makefile** + +`pyproject.toml`: `[project] name = "arm-statements"`, `version = "0"`, `requires-python = ">=3.11,<3.12"`, +`dependencies = ["PyYAML==6.0.3"]`; `[project.optional-dependencies] dev = ["ruff==0.16.10", "mypy==2.4.0", +"pytest==9.1.1", "types-PyYAML==6.0.12.20260906"]`; `[tool.ruff]` и `[tool.ruff.lint]` — как в Global Constraints, +`[tool.ruff.lint.isort] known-first-party = ["arm_statements"]`; `[tool.mypy] python_version = "3.11"`, `strict = true`, +`mypy_path = "."`, `explicit_package_bases = true`; `[tool.pytest.ini_options] testpaths = ["arm_statements/tests"]`, +`pythonpath = ["."]`, `addopts = "--tb=short --strict-markers"`. Без `[build-system]` (пакет не собирается в wheel). + +`Makefile` (рецепты из корня, окружение `.venv`, `BIN := $(CURDIR)/.venv/bin`): `venv` — `uv venv --python 3.11 +--allow-existing .venv` и `uv pip install --python .venv/bin/python -r pyproject.toml --extra dev`; `lint` — `ruff check .`, +`ruff format --check .`, `mypy arm_statements`; `test` — `pytest`; `check: lint test`; `golden-update` — +`GOLDEN_UPDATE=1 $(BIN)/pytest arm_statements/tests/test_golden.py` (тест задачи 20). `help` с описанием целей. + +`.gitignore`: шаблон Python (как у scan-gateway) + `arm_statements/VERSION`, `.codegraph/`, `.superpowers/`, `.bounded`. + +`conftest.py`: пока пустой модуль с docstring (фикстура `rules` появится в задаче 3). `README.md`: назначение пакета, +ссылка на спеку, «копия раскладывается `make -C docs sync-statements`, прямые правки копии запрещены», «как проверить: +`make venv && make check`»; раздел «Публичный API» дописывается в задаче 20. + +- [ ] **Step 3: Run test to verify it fails** + +Run: `make venv && .venv/bin/pytest arm_statements/tests/test_version.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'arm_statements.version'` + +- [ ] **Step 4: Implement `statements_version(path: Path | None = None) -> str | None` in `arm_statements/version.py`** + +`__init__.py` пока экспортирует только `statements_version`. + +- [ ] **Step 5: Run checks** + +Run: `make check` +Expected: ruff, format, mypy — без ошибок; `3 passed`. + +- [ ] **Step 6: Commit** + +```bash +git -C "$R" add pyproject.toml Makefile README.md .gitignore arm_statements +git -C "$R" commit -m "feat: каркас пакета arm_statements, make check, маркер версии копии" +``` + +--- + +### Task 2: Типы входа и выхода, сериализация, сборщики тестовых кандидатов + +**Files:** +- Create: `$R/arm_statements/model.py`, `$R/arm_statements/serialize.py`, `$R/arm_statements/tests/builders.py` +- Test: `$R/arm_statements/tests/test_model.py` + +**Interfaces:** +- Consumes: — +- Produces (`arm_statements/model.py`; все dataclass-ы `frozen=True, slots=True`, последовательности — `tuple`): + - `class Source(StrEnum)`: `OCR = "ocr"`, `EXCEL = "excel"` + - `class DocumentType(StrEnum)`: `BALANCE`, `INCOME`, `DISCLOSURE` (значения = имена) + - `class FormVariant(StrEnum)`: `FULL = "full"`, `SIMPLIFIED = "simplified"` + - `class Edition(StrEnum)`: `RAS_66N = "66n"`, `FSBU4 = "fsbu4"` + - `class PeriodKind(StrEnum)`: `ABSOLUTE = "absolute"`, `CURRENT = "relative:current"`, + `PREV_YEAR_END = "relative:prev_year_end"`, `PREV_PREV_YEAR_END = "relative:prev_prev_year_end"`, + `PREV_PERIOD = "relative:prev_period"`, `NONE = "none"` + - `class LineStatus(StrEnum)`: `OK = "ok"`, `UNREADABLE = "unreadable"`, `MISSING = "missing"`, `DETAIL = "detail"` + - `class Severity(StrEnum)`: `ERROR = "error"`, `INFO = "info"` + - `class DocStatus(StrEnum)`: `DONE = "done"`, `NEEDS_CHECK = "needs_check"` + - `class EnvelopeStatus(StrEnum)`: `DONE = "done"`, `NEEDS_CHECK = "needs_check"`, `ERROR = "error"` + - `class SignForm(StrEnum)`: `PARENTHESES = "parentheses"`, `MINUS = "minus"`, `UNSIGNED = "unsigned"` + - `class RowRole(StrEnum)`: `TITLE = "title"`, `HEADER = "header"`, `NUMBERING = "numbering"`, `DATA = "data"` + - `CellValue = Decimal | date | bool | None` + - `Cell(text: str, value: CellValue, confidence: float, ref: str)` + - `Evidence(okud: Cell | None = None, title: Cell | None = None)` + - `HeaderCells(organization_name, inn, report_date, units, okud, okpo, okopf, okfs, okei, address: Cell | None = None)` + — десять полей, порядок как здесь; кортеж имён — константа `HEADER_FIELDS: tuple[str, ...]` + - `StatementWarning(code: str, severity: Severity, message: str, line_refs: tuple[str, ...] = (), + column_index: int | None = None, field: str | None = None, locator: str | None = None)` + - `FormCandidate(source: Source, locator: str, type_hint: DocumentType | None, evidence: Evidence, header: HeaderCells, + rows: tuple[tuple[Cell | None, ...], ...], segment_breaks: tuple[int, ...] = (), + warnings: tuple[StatementWarning, ...] = ())` — позиция ячейки в строке = номер столбца сетки; строки могут быть + разной длины (недостающие позиции = `None`) + - `HeaderField(text: str | None, confidence: float | None, value: str | None)` + - `DateField(text: str | None, confidence: float | None, value: date | None)` + - `StatementHeader(organization_name: HeaderField, inn: HeaderField, report_date: DateField, units: HeaderField, + okud: HeaderField, okpo: HeaderField, okopf: HeaderField, okfs: HeaderField, okei: HeaderField, + address: HeaderField, form_name: str | None = None, needs_review_fields: tuple[str, ...] = ())` — `form_name` — + заголовок формы как в источнике (`header.form_name` контракта; ставит конвейер задачи 17 из `evidence.title`) + - `StatementColumn(column_index: int, label: str, label_confidence: float | None, period_date: date | None, + period_kind: PeriodKind)` + - `StatementValue(column_index: int, amount: Decimal | None, string_value: str | None, text: str | None, + confidence: float, is_recognized: bool, needs_review: bool = False)` + - `StatementLine(row_index: int, ref: str, line_status: LineStatus, line_code: str | None, + line_code_text: str | None, line_code_confidence: float | None, line_name: str | None, + line_name_confidence: float | None, parent_ref: str | None, needs_review: bool, values: tuple[StatementValue, ...])` + - `FailedRelation(column_index: int | None, column_key: str, relation: str, codes: tuple[str, ...], + expected: Decimal | None, actual: Decimal | None)` — `column_key`: у сервиса `str(column_index)`, у ядра — ключ + колонки хоста; `expected is None` ⇔ цель пуста (нет строки `ok` цели с `amount`), `actual is None` ⇔ пусты все + члены (контракт: `expected`/`actual` — `money`, `null` — «цели нет»; P1-21) + - `StatementChecks(relations_failed: tuple[FailedRelation, ...], soft_relations_failed: tuple[FailedRelation, ...])` + - `StatementDocument(document_type: DocumentType, form_variant: FormVariant | None, locator: str, + header: StatementHeader, columns: tuple[StatementColumn, ...], lines: tuple[StatementLine, ...], + checks: StatementChecks | None, status: DocStatus, warnings: tuple[StatementWarning, ...])` + - `CandidateResult(document: StatementDocument | None, file_warnings: tuple[StatementWarning, ...])` + - `NormalizationResult(documents: tuple[StatementDocument, ...], file_warnings: tuple[StatementWarning, ...])` +- Produces (`arm_statements/serialize.py`): + - `to_jsonable(obj: object) -> Any` — dataclass → `dict` по полям, `StrEnum` → значение, `Decimal` → `str`, `date` → + ISO, `tuple`/`list` → `list`, остальное как есть; у `Cell.value` — тег: `{"decimal": "106311"}`, + `{"date": "2025-09-30"}`, `{"bool": true}`, `null` + - `candidate_from_dict(data: Mapping[str, Any]) -> FormCandidate` — обратная к `to_jsonable` для кандидата +- Produces (`arm_statements/tests/builders.py`, только для тестов): + - `ocr(rows: Sequence[Sequence[str | tuple[str, float] | None]], *, type_hint: DocumentType | None = None, + header: HeaderCells | None = None, title: str | None = None, okud: str | None = None, + locator: str = "file:t.json;table:0") -> FormCandidate` — строка `r` → ячейки `Cell(text, None, conf, f"row:{r}")`, + `str` — уверенность 100, `(text, conf)` — заданная, `None` — позиции нет + - `excel(sheets: Sequence[tuple[str, Sequence[Sequence[str | Decimal | None]]]], *, type_hint=None, header=None, + title: str | None = None, okud: str | None = None, warnings: Sequence[StatementWarning] = ()) -> FormCandidate` — + листы подряд в одной сетке, `segment_breaks` — первая строка каждого листа после первого, `locator` — + `sheet:[,…]`, `ref` — `<лист>!` по правилу Excel (кавычки, если в имени есть что-то кроме букв, цифр и + `_` или оно начинается с цифры; апостроф удваивается); `Decimal` → `Cell(text=<с пробелами тысяч>, value=v, 100)` + - `hdr(**fields: str | tuple[str, float]) -> HeaderCells` + - `place(cells: Mapping[int, str | Decimal | tuple[str, float]], width: int | None = None) -> list[...]` — строка с + ячейками в заданных столбцах, остальные `None` + +- [ ] **Step 1: Write the failing test** + +```python +import json +from datetime import date +from decimal import Decimal as D + +from arm_statements.model import Cell, DocumentType, Evidence, FormCandidate, HeaderCells, Source +from arm_statements.serialize import candidate_from_dict, to_jsonable +from arm_statements.tests.builders import excel, ocr + + +def test_candidate_roundtrip_through_json() -> None: + cand = FormCandidate( + source=Source.EXCEL, + locator="sheet:Баланс", + type_hint=DocumentType.BALANCE, + evidence=Evidence(okud=Cell("0710001", None, 100.0, "Баланс!F3"), title=None), + header=HeaderCells(report_date=Cell("30.09.2025", date(2025, 9, 30), 100.0, "Баланс!C5")), + rows=((Cell("106 311", D("106311"), 100.0, "Баланс!D27"), None, Cell("да", True, 100.0, "Баланс!F27")),), + ) + data = json.loads(json.dumps(to_jsonable(cand), ensure_ascii=False)) + assert data["rows"][0][0]["value"] == {"decimal": "106311"} + assert data["header"]["report_date"]["value"] == {"date": "2025-09-30"} + assert candidate_from_dict(data) == cand + + +def test_excel_builder_refs_follow_excel_quoting() -> None: + cand = excel([("Баланс", [["", "", "", "1110"]]), ("Бухгалтерский баланс. Лист 1", [["", "", "1150"]])]) + assert cand.rows[0][3] is not None and cand.rows[0][3].ref == "Баланс!D1" + assert cand.rows[1][2] is not None and cand.rows[1][2].ref == "'Бухгалтерский баланс. Лист 1'!C1" + assert cand.segment_breaks == (1,) + assert cand.locator == "sheet:Баланс,Бухгалтерский баланс. Лист 1" + + +def test_ocr_builder_cells() -> None: + cand = ocr([["Выручка", ("[190 :", 25.0), None]]) + cell = cand.rows[0][1] + assert cell == Cell("[190 :", None, 25.0, "row:0") + assert cand.rows[0][2] is None and cand.source is Source.OCR +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_model.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'arm_statements.model'` + +- [ ] **Step 3: Implement types, `to_jsonable`, `candidate_from_dict`, builders** — по Interfaces. `to_jsonable` — + `dataclasses.fields` рекурсивно (не `asdict`: нужен тег у `Cell.value`). + +- [ ] **Step 4: Run tests and lint** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: типы шага 1 и выхода шагов 2–8, сериализация кандидата в json" +``` + +--- + +### Task 3: Словари и загрузчик + +**Files:** +- Create: `$R/arm_statements/rules/*.yaml` (восемь файлов из «Структуры репозитория»), `$R/arm_statements/loader.py`, + `$R/arm_statements/issues.py` +- Modify: `$R/arm_statements/tests/conftest.py` (фикстура `rules`) +- Test: `$R/arm_statements/tests/test_loader.py` + +**Interfaces:** +- Consumes: перечисления из `model.py`. +- Produces (`arm_statements/loader.py`): + - `class RulesError(ValueError)` — словарь не прошёл проверку (сообщение называет файл и ключ) + - `class CodeSection(StrEnum)`: `BALANCE`, `INCOME`, `CAPITAL`, `CASH_FLOW`, `NOTES`, `TARGET_USE` + - `CodeRange(section: CodeSection, low: int, high: int)`; `CodeRanges(ranges: tuple[CodeRange, ...], + min_share: Decimal, min_codes: int, min_codes_continuation: int)` — `min_codes_continuation`: минимум кодов + ослабленного поиска столбца (сегмент-продолжение, спека «Шаг 2 — сегменты…»: «минимум один») и листа-продолжения без + маркера (`sheet_joins`) + - `RelationRule(form: DocumentType, variant: FormVariant, relation: str, target: str, + terms: tuple[tuple[int, str], ...], strict: bool, abs_terms: frozenset[str])` — `terms`: `(+1 | -1, код)` + - `CheckRules(tolerance: Decimal, rounding_tolerance_percent: Decimal, subline_check: bool, + relations: tuple[RelationRule, ...])` с методами `relations_for(form: DocumentType, variant: FormVariant) -> + tuple[RelationRule, ...]` и `abs_codes(form: DocumentType, variant: FormVariant) -> frozenset[str]` (объединение + `abs_terms` набора) + - `PeriodPattern(kind: str, pattern: re.Pattern[str])`; `PeriodRules(relative: tuple[tuple[PeriodKind, + re.Pattern[str]], ...], absolute: tuple[PeriodPattern, ...], months: tuple[tuple[str, int], ...], + min_year: int, max_year: int, max_text_length: int)` + - `HeaderRules(form_titles: Mapping[DocumentType, tuple[re.Pattern[str], ...]], okud: Mapping[DocumentType, + tuple[re.Pattern[str], ...]], okud_code: re.Pattern[str], continuation: tuple[re.Pattern[str], ...], + code_caption: re.Pattern[str], name_caption: re.Pattern[str], explanations_caption: re.Pattern[str], + column_exclusions: tuple[re.Pattern[str], ...], labels: Mapping[str, tuple[re.Pattern[str], ...]], + auditor: tuple[re.Pattern[str], ...], legal_form: tuple[re.Pattern[str], ...])` — ключи `labels` ровно + `LABEL_KEYS = ("organization", "inn", "okpo", "okopf", "okfs", "okopf_okfs", "okei", "units", "address", "date")`; + какая нормализация текста к какому ключу применяется — раздел «Публичный API пакета», «Словари» + - `UnitRules(spellings: tuple[tuple[re.Pattern[str], str], ...], okei: Mapping[str, str], thousands: str, + fractional_share_percent: int)` + - `CodeOrders(editions: tuple[Edition, ...], sequences: Mapping[tuple[Edition, DocumentType, FormVariant], + tuple[str, ...]])` с методом `sequence(edition: Edition, form: DocumentType, variant: FormVariant) -> + tuple[str, ...]`; `editions` — порядок редакций в файле (`(RAS_66N, FSBU4)`); `sequence(…, DocumentType.DISCLOSURE, + …)` → `()` (у пояснений перечня нет, структурная проверка к ним не применяется) + - `WarningRule(code: str, severity: Severity, scope: Literal["document", "file"])` + - `Rules(checks: CheckRules, periods: PeriodRules, headers: HeaderRules, units: UnitRules, code_ranges: CodeRanges, + code_orders: CodeOrders, lookalikes: Mapping[str, str], warnings: Mapping[str, WarningRule])` + - `load_rules(directory: Path | None = None) -> Rules`; `default_rules() -> Rules` (`functools.cache`, каталог + `Path(__file__).parent / "rules"`) +- Produces (`arm_statements/issues.py`): + - `class WarningCode(StrEnum)` — по члену на код словаря `statement_warnings.yaml` (имя = значение = код: + `WarningCode.JSON_FAILED == "JSON_FAILED"`); хосты передают в `make_warning` член перечисления, а не строку-литерал + - `make_warning(code: str, message: str, rules: Rules, *, line_refs: Sequence[str] = (), column_index: int | None = + None, field: str | None = None, locator: str | None = None) -> StatementWarning` — уровень из + `rules.warnings[code]`; `code` — `WarningCode` или строка (`StrEnum` — подкласс `str`); неизвестный код — + `KeyError` с текстом «код предупреждения не задан в statement_warnings.yaml» (предупреждение с выдуманным кодом + не создаётся) +- Produces (`conftest.py`): фикстура `rules() -> Rules` (`default_rules()`). +- Produces (`loader.py`): `LABEL_KEYS: tuple[str, ...]` — ключи `headers.labels` в порядке словаря. + +Содержимое словарей (ключи фиксированы; значения — из доноров и спеки): + +- `statement_checks.yaml`: `tolerance: 1`, `rounding_tolerance_percent: 0.001`, `subline_check: true`, `relations:` — + элементы `{form, variant, relation, strict, abs_terms?}`. `variant: full` — все 13 строк tabular-parser + `api/rules/statement_checks.yaml` дословно (с 1105 в `1100`, 1215 в `1200`, 1530 в `1500`). `variant: simplified` + (спека «Шаг 7», §7 «Упрощённый набор»): `BALANCE "1600 = 1150 + 1170 + 1210 + 1230 + 1250"` strict; + `BALANCE "1700 = 1300 + 1350 + 1360 + 1410 + 1450 + 1510 + 1520 + 1550"` strict; `BALANCE "1600 = 1700"` strict; + `INCOME "2400 = 2110 - 2120 - 2330 + 2340 - 2350 - 2410"` **не** strict, `abs_terms: ["2120", "2330", "2350", "2410"]`. +- `statement_periods.yaml`: `max_text_length: 200`; `years: {min: 1990, max: 2100}`; `months:` — основа → номер + (`январ: 1` … `ма: 5` … `декабр: 12`, как `_MONTHS` scan-gateway `dates.py`); `relative:` — по порядку + `{kind: "relative:prev_prev_year_end", pattern: 'предшествующ\w*\s+предыдущ'}`, + `{kind: "relative:prev_year_end", pattern: 'декабр\w*\s+предыдущ'}`, + `{kind: "relative:prev_period", pattern: 'аналогичн\w*\s+период\w*\s+предыдущ'}`, + `{kind: "relative:current", pattern: 'отч[её]тн\w*\s+(дат|период)'}`; `absolute:` — по приоритету §11.3 п. 3 + элементы `{kind, pattern}` с `kind ∈ {range_end, day_month_year, quarter, half, months, month_range, year}` и + именованными группами `d`, `m` (месяц числом), `mw` (месяц словом), `y`, `q` (номер квартала/полугодия словом или + римской/арабской цифрой), `n` (число месяцев); регулярки — из `_PATTERNS` scan-gateway `dates.py` (они надмножество + словаря tabular-parser `statement_periods.yaml`; `for_year` tabular-parser покрывает числовой разбор «2024»). +- `statement_headers.yaml`: из tabular-parser `api/rules/statement_headers.yaml` — `form_titles` (+ заголовки + `statement_labels.yaml` scan-gateway, «пояснени…», «^расшифровк» → `DISCLOSURE`), `okud`, `okud_code`, `continuation`, + `labels`, `auditor`, `legal_form`; новые ключи `captions: {code: '^код(ы|\s+строки|\s+показателя)?$', name: + '^наименование', explanations: '^пояснени', exclusions: ['изменени', 'отклонени', '%', 'темп', 'доля', 'примечани']}`. + Ключи `line_codes`, `units_thousands`, `fractional_share_percent`, `column_numbering`, `code_caption`, + `explanations_caption` в пакет не переносятся (заменены отдельными словарями и правилами). +- `statement_units.yaml`: `units:` — список `{canonical, patterns}` по порядку: `тыс. руб.` (`'тыс\w*\.?\s*руб'`, + `'тысяч\w*\s+рубл'`), `млн руб.` (`'млн\w*\.?\s*руб'`, `'миллион\w*\s+рубл'`), `руб.` (`'руб'`); `okei: {"384": "тыс. + руб.", "383": "руб.", "385": "млн руб."}`; `thousands: "тыс. руб."`; `fractional_share_percent: 10`. +- `statement_code_ranges.yaml`: `sections: {BALANCE: [1100, 1799], INCOME: [2100, 2999], CAPITAL: [3100, 3699], + CASH_FLOW: [4100, 4499], NOTES: [5000, 5999], TARGET_USE: [6100, 6499]}`, `min_share: 0.6`, `min_codes: 3`, + `min_codes_continuation: 1` (из `line_codes.min_codes_continuation` tabular-parser). +- `statement_code_orders.yaml`: `cp` из П-5. +- `statement_warnings.yaml`: `warnings: {: {severity, scope}}` — ровно таблица спеки «Шаг 8 — предупреждения» + (тест ниже), в том числе `XLS_NO_CODEPAGE: {severity: info, scope: file}` (книга xls без кодовой страницы, P1-23). +- `ocr_lookalikes.yaml`: `lookalikes:` — `О о O o → 0`, `З з → 3`, `б → 6`, `S s → 5`, `I l | і → 1`, `Ч → 4`. + +Проверки загрузчика (`RulesError`): каждая регулярка компилируется; `relation` — `<4 цифры> = <4 цифры> ([+-] <4 цифры>)*`, +`abs_terms ⊆` членов; `variant ∈ {full, simplified}`; `code_orders.version == 1`; в каждой редакции и форме в `full` +каждый код встречается `1 + (число его вхождений в repeats)` раз, в `simplified` — не чаще; все коды перечня — +четыре цифры; `severity ∈ {error, info}`, `scope ∈ {document, file}`; множество кодов `statement_warnings.yaml` равно +множеству `WarningCode`; ключи `headers.labels` — ровно `LABEL_KEYS`; `1 ≤ min_codes_continuation ≤ min_codes`; у +`units.okei` значения есть среди `canonical`. + +- [ ] **Step 1: Write the failing test** + +```python +import re +from decimal import Decimal +from pathlib import Path + +import pytest +import yaml + +from arm_statements.issues import WarningCode, make_warning +from arm_statements.loader import LABEL_KEYS, RulesError, Rules, load_rules +from arm_statements.model import DocumentType, Edition, FormVariant, Severity + +RULES_DIR = Path(__file__).parent.parent / "rules" + +SPEC_WARNINGS = { + "JSON_FAILED": ("error", "file"), "TABLE_PARSE_ERROR": ("error", "file"), "TABLE_LOST": ("error", "file"), + "TABLE_SKIPPED": ("info", "file"), "CODE_COLUMN_NOT_FOUND": ("error", "document"), + "SEGMENT_WITHOUT_CODES": ("error", "document"), "SHEET_NOT_JOINED": ("error", "document"), + "CONTINUATION_TYPE_CONFLICT": ("error", "document"), "SEGMENT_COLUMNS_MISMATCH": ("error", "document"), + "COLUMN_WITHOUT_DATE": ("error", "document"), "COLUMN_DATE_UNCERTAIN": ("error", "document"), + "HEADER_ROW_NOT_FOUND": ("error", "document"), "TYPE_CONFLICT": ("error", "document"), + "NO_ORGANIZATION_NO_INN": ("error", "document"), "PERIOD_UNKNOWN": ("error", "document"), + "REPORT_DATE_UNCERTAIN": ("error", "document"), "XLSB_SCALE_SUSPECT": ("error", "document"), + "INN_NOT_FOUND": ("info", "document"), "INN_UNCERTAIN": ("info", "document"), + "ORGANIZATION_NOT_FOUND": ("info", "document"), "ORGANIZATION_UNCERTAIN": ("info", "document"), + "REPORT_DATE_UNPARSED": ("info", "document"), "UNITS_NOT_FOUND": ("info", "document"), + "FILE_TYPE_MISMATCH": ("info", "file"), "COLUMN_SKIPPED": ("info", "document"), + "FRACTIONAL_IN_THOUSANDS": ("info", "document"), "XLSB_NO_FORMATS": ("info", "document"), + "NUMBER_FORMAT_UNPARSED": ("info", "document"), "VALUE_OUT_OF_RANGE": ("info", "document"), + "SHEET_OTHER": ("info", "file"), "ROW_SPLIT": ("info", "document"), "XLS_NO_CODEPAGE": ("info", "file"), +} + + +def test_warning_table_matches_spec(rules: Rules) -> None: + assert {c: (w.severity.value, w.scope) for c, w in rules.warnings.items()} == SPEC_WARNINGS + + +def test_simplified_income_relation_is_soft(rules: Rules) -> None: + (rule,) = rules.checks.relations_for(DocumentType.INCOME, FormVariant.SIMPLIFIED) + assert rule.target == "2400" and not rule.strict + assert rule.abs_terms == frozenset({"2120", "2330", "2350", "2410"}) + assert rules.checks.abs_codes(DocumentType.INCOME, FormVariant.FULL) == frozenset( + {"2120", "2210", "2220", "2330", "2350", "2410"} + ) + + +def test_code_orders_with_repeats(rules: Rules) -> None: + fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.BALANCE, FormVariant.FULL) + assert fsbu.count("1220") == len(("1220", "1220")) and "1120" not in fsbu + ras = rules.code_orders.sequence(Edition.RAS_66N, DocumentType.BALANCE, FormVariant.FULL) + assert "1120" in ras and ras.index("1110") < ras.index("1100") + assert rules.code_orders.editions == (Edition.RAS_66N, Edition.FSBU4) + assert rules.code_orders.sequence(Edition.FSBU4, DocumentType.DISCLOSURE, FormVariant.FULL) == () + + +def test_public_rule_types(rules: Rules) -> None: + assert isinstance(rules.checks.tolerance, Decimal) and rules.checks.tolerance == Decimal("1") + assert all(isinstance(e, Edition) for e in rules.code_orders.editions) + fsbu = rules.code_orders.sequence(Edition.FSBU4, DocumentType.INCOME, FormVariant.SIMPLIFIED) + assert isinstance(fsbu, tuple) and all(isinstance(c, str) for c in fsbu) + assert (rules.code_ranges.min_codes_continuation, rules.code_ranges.min_codes) == (1, 3) + assert tuple(rules.headers.labels) == LABEL_KEYS + + +def test_warning_code_enum_matches_dictionary(rules: Rules) -> None: + assert {c.value for c in WarningCode} == set(rules.warnings) == set(SPEC_WARNINGS) + assert WarningCode.XLS_NO_CODEPAGE == "XLS_NO_CODEPAGE" + + +def test_no_code_to_name_mapping_anywhere() -> None: + code = re.compile(r"^\d{4,5}$") + + def walk(node: object) -> None: + if isinstance(node, dict): + for key, value in node.items(): + assert not (code.match(str(key)) and isinstance(value, str) and re.search(r"[А-Яа-яЁё]", value)) + walk(value) + elif isinstance(node, list): + for item in node: + walk(item) + + for path in RULES_DIR.glob("*.yaml"): + walk(yaml.safe_load(path.read_text(encoding="utf-8"))) + + +def test_broken_repeat_count_rejected(tmp_path: Path) -> None: + for path in RULES_DIR.glob("*.yaml"): + (tmp_path / path.name).write_bytes(path.read_bytes()) + orders = tmp_path / "statement_code_orders.yaml" + orders.write_text(orders.read_text(encoding="utf-8").replace('- "1520" # «Обязательство', '# "1520"'), + encoding="utf-8") + with pytest.raises(RulesError, match="statement_code_orders"): + load_rules(tmp_path) + + +def test_unknown_severity_rejected(tmp_path: Path) -> None: + for path in RULES_DIR.glob("*.yaml"): + (tmp_path / path.name).write_bytes(path.read_bytes()) + target = tmp_path / "statement_warnings.yaml" + target.write_text(target.read_text(encoding="utf-8").replace("severity: info", "severity: warn", 1), + encoding="utf-8") + with pytest.raises(RulesError, match="statement_warnings"): + load_rules(tmp_path) + + +def test_make_warning_takes_severity_from_dictionary(rules: Rules) -> None: + warning = make_warning(WarningCode.COLUMN_SKIPPED, "колонка «Изменение, %» пропущена", rules, column_index=5) + assert (warning.code, warning.severity, warning.column_index) == ("COLUMN_SKIPPED", Severity.INFO, 5) + assert make_warning("JSON_FAILED", "x", rules).severity is Severity.ERROR + + +def test_make_warning_unknown_code_raises(rules: Rules) -> None: + with pytest.raises(KeyError, match="NOT_A_CODE"): + make_warning("NOT_A_CODE", "x", rules) + + +def test_label_keys_are_checked(tmp_path: Path) -> None: + for path in RULES_DIR.glob("*.yaml"): + (tmp_path / path.name).write_bytes(path.read_bytes()) + target = tmp_path / "statement_headers.yaml" + target.write_text(target.read_text(encoding="utf-8").replace(" okpo:", " okpo_x:", 1), encoding="utf-8") + with pytest.raises(RulesError, match="statement_headers"): + load_rules(tmp_path) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_loader.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'arm_statements.loader'` + +- [ ] **Step 3: Write the eight YAML files and implement `loader.py`, `issues.py`, фикстуру `rules`** — по Interfaces и + перечню выше. YAML читается `yaml.safe_load`, результат приводится к типам через явные проверки (`isinstance`) — + `Any` из YAML не утекает в `Rules`. + +- [ ] **Step 4: Verify byte copy and run tests** + +Run: `cmp arm_statements/rules/statement_code_orders.yaml /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/contracts/statement-code-orders.yaml && make check` +Expected: `cmp` молчит; всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: словари нормализации (§7, периоды, заголовки, единицы, коды, предупреждения) и загрузчик" +``` + +--- + +### Task 4: Очистка OCR-текста и разбор кода + +**Files:** +- Create: `$R/arm_statements/text.py`, `$R/arm_statements/codes.py` +- Test: `$R/arm_statements/tests/test_text_codes.py` + +**Interfaces:** +- Consumes: `Rules` (`lookalikes`, `code_ranges`, `headers`). +- Produces (`arm_statements/text.py`): + - `fix_lookalikes(text: str, lookalikes: Mapping[str, str], *, anywhere: bool = False) -> str` — NBSP → пробел; если + первая строка текста состоит только из цифр, пробелов, `|`, точек, запятых и двоеточий — `|` заменяется пробелом + (разделитель клеток, «2|0|2|4|» → «2 0 2 4 »); затем двойники → цифры вплотную к цифре в несколько проходов + (`anywhere=True` — без условия соседства: для нумерации и «цифры под видом буквы») + - `has_digit(text: str | None) -> bool` + - `is_disguised_digit(text: str, lookalikes: Mapping[str, str]) -> bool` — после `fix_lookalikes(anywhere=True)` есть + цифра и нет других букв (`'I'`, `'| О'` → `True`; `'Косарев'`, `'(расшифровка подписи)'` → `False`) + - `normalize_caption(text: str | None) -> str` — нижний регистр, `ё → е`, всё, кроме букв, цифр и `%`, → пробел, + пробелы схлопнуты, по краям обрезано; `None` → `""`. Под неё записаны подписи колонок (`headers.code_caption`, + `name_caption`, `explanations_caption`, `column_exclusions`), `units.spellings` и `periods.relative` + - `normalize_label(text: str | None) -> str` — нормализация меток шапки и заголовков, дословно `normalize_text` + tabular-parser: удалить невидимые `U+00AD`, `U+200B`, `U+FEFF`; нижний регистр; `ё → е`; любые пробельные символы + (включая NBSP и переносы строк), `-`, `U+2010…U+2015`, `U+2212` подряд → один пробел; по краям обрезано; `None` → + `""`. Пунктуация (точки, скобки, кавычки, `/`) **сохраняется**. Под неё записаны `headers.form_titles`, `okud`, + `okud_code`, `continuation`, `labels`, `auditor`, `legal_form` (P1-22) +- Produces (`arm_statements/codes.py`): + - `CodeParse(code: str | None, fixed: bool)` — `fixed`: при разборе заменён двойник + - `parse_code(text: str | None, rules: Rules) -> CodeParse` — однострочный текст по спеке «Диапазоны кодов и разбор + кода»: убрать пробелы → двойники (с `|`; если результат не код — повтор без `|`) → краевая пунктуация → `^\d{4,5} + (\.0)?$`, первые четыре цифры в объединении отрезков; многострочный текст → `CodeParse(None, False)` + - `is_code_cell(text: str | None, rules: Rules) -> bool` — однострочный — `parse_code(...).code is not None`; + многострочный — каждая непустая часть код + - `code_parts(text: str | None) -> tuple[str, ...]` — непустые строки ячейки, обрезанные + - `section_of(code: str, rules: Rules) -> CodeSection | None` — отрезок по первым четырём цифрам + - `majority_section(codes: Iterable[str], rules: Rules) -> CodeSection | None` — отрезок большинства (ничья — первый + по порядку отрезков словаря; пусто — `None`) + - `document_type_of(section: CodeSection) -> DocumentType` — `BALANCE`/`INCOME` как есть, прочие → `DISCLOSURE` + - `sheet_joins(form_type: DocumentType, sheet_codes: Sequence[str], sheet_amount_columns: int, + form_amount_columns: int, rules: Rules) -> bool` — правило листа без маркера и шапки (спека «Шаг 1 — + многостраничная форма»): все коды в отрезке формы и при трёх и более кодах число колонок сумм совпадает; кодов нет + — `False`; кодов меньше `code_ranges.min_codes_continuation` — `False` (для адаптера Excel плана 4) + +- [ ] **Step 1: Write the failing test** + +```python +import pytest + +from arm_statements.codes import is_code_cell, majority_section, parse_code, sheet_joins +from arm_statements.loader import CodeSection, Rules +from arm_statements.model import DocumentType +from arm_statements.text import fix_lookalikes, is_disguised_digit, normalize_caption, normalize_label + + +@pytest.mark.parametrize( + ("raw", "code", "fixed"), + [ + ("1220 ,", "1220", False), + ("| 140", "1140", True), + ("I1О0", "1100", True), + ("1110.0", "1110", False), + ("12301", "12301", False), + ("3450", "3450", False), + ("2024", None, False), + ("1050", None, False), + ("7450", None, False), + ("ИЧ,250", None, False), + ("изо :", None, False), + ("НИ", None, False), + ("1500\n1700", None, False), + ("2", None, False), + ], +) +def test_parse_code(rules: Rules, raw: str, code: str | None, fixed: bool) -> None: + assert parse_code(raw, rules) == (code, fixed) + + +def test_multiline_code_cell(rules: Rules) -> None: + assert is_code_cell("1500\n1700", rules) + assert not is_code_cell("44 30\nI |", rules) + + +def test_lookalikes_only_near_digit(rules: Rules) -> None: + assert fix_lookalikes("2ОО5", rules.lookalikes) == "2005" + assert fix_lookalikes("2|0|2|4|", rules.lookalikes).split() == ["2", "0", "2", "4"] + assert fix_lookalikes("Основные средства", rules.lookalikes) == "Основные средства" + assert fix_lookalikes("З", rules.lookalikes) == "З" + assert fix_lookalikes("З", rules.lookalikes, anywhere=True) == "3" + + +def test_disguised_digit(rules: Rules) -> None: + assert is_disguised_digit("I", rules.lookalikes) and is_disguised_digit("| О", rules.lookalikes) + assert not is_disguised_digit("Косарев", rules.lookalikes) + assert not is_disguised_digit("(расшифровка подписи)", rules.lookalikes) + + +def test_caption_normalization() -> None: + assert normalize_caption("Код строки") == "код строки" + assert normalize_caption("Изменение, %") == "изменение %" + assert normalize_caption("Наименование\nпоказателя") == "наименование показателя" + + +def test_label_normalization_keeps_punctuation(rules: Rules) -> None: + assert normalize_label("Местонахождение (адрес)") == "местонахождение (адрес)" + assert normalize_label("по ОКОПФ / ОКФС") == "по окопф / окфс" + assert normalize_label("Форма\u00a00710001 с.2") == "форма 0710001 с.2" + assert normalize_label("Отчёт о финансовых\nрезультатах") == "отчет о финансовых результатах" + assert normalize_label("за\u00adдол\u00adжен\u2014ность") == "задолжен ность" + assert normalize_label(None) == "" + for key, patterns in rules.headers.labels.items(): + assert patterns, key + assert any(p.search(normalize_label("Местонахождение (адрес)")) for p in rules.headers.labels["address"]) + assert any(p.search(normalize_label("по ОКОПФ / ОКФС")) for p in rules.headers.labels["okopf_okfs"]) + + +def test_majority_section_and_join(rules: Rules) -> None: + assert majority_section(["1110", "1150", "2110"], rules) is CodeSection.BALANCE + assert sheet_joins(DocumentType.INCOME, ["2400"], 1, 3, rules) + assert not sheet_joins(DocumentType.BALANCE, ["1110", "1150", "1100"], 4, 3, rules) + assert not sheet_joins(DocumentType.BALANCE, ["2110"], 3, 3, rules) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_text_codes.py -v` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement `text.py` and `codes.py`** по Interfaces (донор — scan-gateway `text.py::fix_lookalikes`, + tabular-parser `columns.py::line_code`). Краевая пунктуация — всё, что не буква и не цифра, по краям после удаления + пробелов. + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: буквы-двойники, нормализация подписей, разбор кода строки и отрезки кодов" +``` + +--- + +### Task 5: Даты и подписи колонок + +**Files:** +- Create: `$R/arm_statements/dates.py` +- Test: `$R/arm_statements/tests/test_dates.py` + +**Interfaces:** +- Consumes: `Rules.periods`, `Rules.headers.column_exclusions`, `fix_lookalikes`, `normalize_caption`. +- Produces (`arm_statements/dates.py`): + - `parse_date(text: str | None, rules: Rules) -> date | None` — текст уже после очистки; длиннее + `max_text_length` (без пробелов по краям) → `None`; сначала текстовые формы `absolute` по порядку словаря по всему + тексту, затем числовой разбор первой строки, затем всего текста через пробел (алгоритм `_numeric_date`, + `_from_digits`, `_from_parts`, `_year` scan-gateway `dates.py`: три группы; 8 цифр ДДММГГГГ / ГГГГММДД / ГГГГДДММ; + 4 → 31.12 года в `[min_year, max_year]`; 6 → ДДММГГ; больше трёх групп — первые три; двузначный год → 20YY) + - `PeriodLabel(kind: PeriodKind, date: date | None, excluded: bool, bare_year: bool)` + - `parse_period_label(cell: Cell | None, rules: Rules) -> PeriodLabel` — `None`/пустой текст → `(NONE, None, False, + False)`; `Cell.value` — `date` → `ABSOLUTE` с этой датой; иначе `fix_lookalikes(text)`; исключение + (`column_exclusions` по `normalize_caption`) → `excluded=True`, `kind=NONE`; относительная подпись (словарь + `relative` по `normalize_caption` очищенного текста, по порядку) → её `kind`, `date=None`; иначе `parse_date` → `ABSOLUTE`; иначе `NONE`. `bare_year` — текст + после очистки — четыре цифры года + - `resolve_relative(kind: PeriodKind, effective: date | None) -> date | None` — `CURRENT` → `effective`; + `PREV_YEAR_END` → 31.12 года − 1; `PREV_PREV_YEAR_END` → 31.12 года − 2; `PREV_PERIOD` → `effective` − 1 год + (29.02 → 28.02); `effective is None` → `None`; `ABSOLUTE` и `NONE` → `None` (не исключение: у таких колонок + дата не вычисляется — хост берёт её из подписи или от аналитика, P1-24) + - `expected_dates(effective: date) -> frozenset[date]` — `effective`, она же − 1 и − 2 года (29.02 → 28.02), 31.12 + года − 1 и года − 2 + +- [ ] **Step 1: Write the failing test** + +```python +from datetime import date + +import pytest + +from arm_statements.dates import expected_dates, parse_date, parse_period_label, resolve_relative +from arm_statements.loader import Rules +from arm_statements.model import Cell, PeriodKind +from arm_statements.text import fix_lookalikes + +# Обязательные строки scan-gateway test_dates.py::test_parses_real_forms (спека «Шаг 2 — подпись и дата колонки») +REAL_FORMS = [ + ("на 30 сентября 2025 г.", date(2025, 9, 30)), ("На 30 июня\n2025 г", date(2025, 6, 30)), + ("31 декабря 2024 года", date(2024, 12, 31)), ("30 сентября 2025 r.", date(2025, 9, 30)), + ("с 01.01.2025 по 30.09.2025", date(2025, 9, 30)), ("I квартал 2026", date(2026, 3, 31)), + ("1-й кв. 2026", date(2026, 3, 31)), ("первый квартал 2025 г.", date(2025, 3, 31)), + ("1 полугодие 2026", date(2026, 6, 30)), ("1 п/г 2026", date(2026, 6, 30)), + ("9 месяцев 2025", date(2025, 9, 30)), ("9 мес. 2025", date(2025, 9, 30)), + ("За январь - июнь\n2025 г.", date(2025, 6, 30)), ("январь – сентябрь 2025", date(2025, 9, 30)), + ("за 2025 г.", date(2025, 12, 31)), ("2024 год", date(2024, 12, 31)), ("30.09.2025", date(2025, 9, 30)), + ("2025 03 31", date(2025, 3, 31)), ("30/09/25", date(2025, 9, 30)), ("2 0 2 4", date(2024, 12, 31)), + ("30 09 2 0 2 5", date(2025, 9, 30)), ("30092025", date(2025, 9, 30)), ("20250930", date(2025, 9, 30)), + ("30 09 25", date(2025, 9, 30)), ("30.09.2025 0:00:00", date(2025, 9, 30)), ("2|0|2|4|", date(2024, 12, 31)), + ("3l.12.2О24", date(2024, 12, 31)), +] + + +@pytest.mark.parametrize(("raw", "expected"), REAL_FORMS) +def test_parses_real_forms(rules: Rules, raw: str, expected: date) -> None: + assert parse_date(fix_lookalikes(raw, rules.lookalikes), rules) == expected + label = parse_period_label(Cell(raw, None, 100.0, "row:0"), rules) + assert (label.kind, label.date) == (PeriodKind.ABSOLUTE, expected) + + +@pytest.mark.parametrize("raw", ["Общество с ограниченной ответственностью", "", "+", "тыс. руб.", "1999999999999"]) +def test_not_a_date(rules: Rules, raw: str) -> None: + assert parse_date(raw, rules) is None + + +def test_long_text_not_parsed(rules: Rules) -> None: + assert parse_date("на 30 сентября 2025 г." + "x" * 200, rules) is None + + +@pytest.mark.parametrize( + ("raw", "kind"), + [ + ("На отчетную дату", PeriodKind.CURRENT), + ("За отчётный период", PeriodKind.CURRENT), + ("На 31 декабря предыдущего года", PeriodKind.PREV_YEAR_END), + ("На 31 декабря года, предшествующего предыдущему", PeriodKind.PREV_PREV_YEAR_END), + ("За аналогичный период предыдущего года", PeriodKind.PREV_PERIOD), + ], +) +def test_relative_labels(rules: Rules, raw: str, kind: PeriodKind) -> None: + assert parse_period_label(Cell(raw, None, 100.0, "row:0"), rules).kind is kind + + +def test_exclusion_and_excel_date_value(rules: Rules) -> None: + assert parse_period_label(Cell("Изменение, %", None, 100.0, "A!F1"), rules).excluded + label = parse_period_label(Cell("30.09.2025", date(2025, 9, 30), 100.0, "A!D1"), rules) + assert (label.kind, label.date) == (PeriodKind.ABSOLUTE, date(2025, 9, 30)) + assert parse_period_label(Cell("2024", None, 100.0, "A!E1"), rules).bare_year + + +def test_resolve_relative_and_expected() -> None: + eff = date(2025, 9, 30) + assert resolve_relative(PeriodKind.PREV_YEAR_END, eff) == date(2024, 12, 31) + assert resolve_relative(PeriodKind.PREV_PREV_YEAR_END, eff) == date(2023, 12, 31) + assert resolve_relative(PeriodKind.PREV_PERIOD, date(2024, 2, 29)) == date(2023, 2, 28) + assert resolve_relative(PeriodKind.CURRENT, None) is None + assert resolve_relative(PeriodKind.ABSOLUTE, eff) is None and resolve_relative(PeriodKind.NONE, eff) is None + assert expected_dates(eff) == frozenset( + {eff, date(2024, 9, 30), date(2023, 9, 30), date(2024, 12, 31), date(2023, 12, 31)} + ) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_dates.py -v` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement `dates.py`** — обработчик на каждый `kind` словаря `absolute` (код), регулярки — словарь. + Длину проверять **до** `fix_lookalikes` и регулярок (backtracking, решение № 31 среза 3a). + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное; тест длинного текста — доли секунды. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: словарь периодов — даты, относительные подписи бланка 66н, исключения колонок" +``` + +--- + +### Task 6: Сумма и значение ячейки (шаг 4, ячейка) + +**Files:** +- Create: `$R/arm_statements/amounts.py` +- Test: `$R/arm_statements/tests/test_amounts.py` + +**Interfaces:** +- Consumes: `Source`, `Cell`, `StatementValue`, `SignForm`, `Rules.lookalikes`, `fix_lookalikes`. +- Produces (`arm_statements/amounts.py`): + - `AmountParse(amount: Decimal | None, string_value: str | None, is_recognized: bool, sign_form: SignForm | None)` — + `sign_form` есть, когда ядро числа допустимо (стадия 4 пройдена), иначе `None` + - `parse_amount(text: str | None, source: Source, rules: Rules) -> AmountParse` — стадии 1–6 спеки «Шаг 4 — значение + ячейки» дословно; единственное место, где разбор зависит от `source` + - `sign_form_of(text: str | None, rules: Rules) -> SignForm | None` — форма знака по тексту (`parse_amount(text, + Source.OCR, rules).sign_form`; от источника не зависит) + - `ValueBuild(value: StatementValue, out_of_range: bool)` + - `build_value(cell: Cell | None, column_index: int, source: Source, rules: Rules, *, split_part: bool = False) -> + ValueBuild` — `cell is None` → `amount=None, string_value=None, text=None, confidence=100.0, is_recognized=True`; + `cell.value` — `Decimal` → квантование half-up до 0,01, больше 16 целых цифр → `amount=None`, `string_value=text`, + `is_recognized=False`, `out_of_range=True`; `date`/`bool` → `amount=None`, `string_value=text`, + `is_recognized=False`; иначе — `parse_amount(cell.text, ...)`; текст из нескольких непустых строк (после `strip`) + при `split_part=False` → `amount=None`, `string_value=text`, `is_recognized=False`; `split_part=True` → + `is_recognized=False` всегда; `text` — всегда `cell.text`, `confidence` — `cell.confidence` + +- [ ] **Step 1: Write the failing test** + +```python +from datetime import date +from decimal import Decimal as D + +import pytest + +from arm_statements.amounts import build_value, parse_amount, sign_form_of +from arm_statements.loader import Rules +from arm_statements.model import Cell, SignForm, Source + +OCR, EXCEL = Source.OCR, Source.EXCEL + + +@pytest.mark.parametrize( + ("raw", "amount", "string_value", "recognized"), + [ + ("1 2О5", D("1205"), None, False), + ("42831 1", D("428311"), None, False), + ("(7744536}", D("-7744536"), None, False), + ("211(1 |", None, "211(1 |", False), + ("(Ю4)", None, "(Ю4)", False), + (": : 2856983 = ■", D("2856983"), None, False), + ("| : 122477", D("122477"), None, False), + ("|122477", D("122477"), None, False), + ("{38378)", None, "{38378)", False), + ("4,200", D("4200"), None, True), + ("1,234,567", D("1234567"), None, True), + ("837.994", D("837994"), None, True), + ("1 234.567", D("1234567"), None, True), + ("4,2", D("4.2"), None, True), + ("1 234,56", D("1234.56"), None, True), + ("1.234,56", D("123456"), None, False), + ("0,125", None, "0,125", False), + ("1 234 567", D("1234567"), None, True), + ("(1 234)", D("-1234"), None, True), + ("−500", D("-500"), None, True), + ("12:34", None, "12:34", False), + ("1 234 руб.", D("1234"), None, False), + ("—", None, None, True), + ("-", None, None, True), + ("", None, None, True), + ("I", None, "I", False), + ], +) +def test_parse_amount_ocr(rules: Rules, raw: str, amount: D | None, string_value: str | None, recognized: bool) -> None: + parsed = parse_amount(raw, OCR, rules) + assert (parsed.amount, parsed.string_value, parsed.is_recognized) == (amount, string_value, recognized) + + +def test_comma_depends_on_source(rules: Rules) -> None: + assert parse_amount("4,200", EXCEL, rules).amount == D("4.20") + assert parse_amount("1.500", EXCEL, rules).amount == D("1.5") + + +def test_sign_forms(rules: Rules) -> None: + assert sign_form_of("(7744536}", rules) is SignForm.PARENTHESES + assert sign_form_of("-1 234", rules) is SignForm.MINUS + assert sign_form_of("38378", rules) is SignForm.UNSIGNED + assert sign_form_of("{38378)", rules) is None + + +def test_build_value_excel_number_and_out_of_range(rules: Rules) -> None: + ok = build_value(Cell("106 311", D("106311.456"), 100.0, "Баланс!D27"), 3, EXCEL, rules) + assert (ok.value.amount, ok.value.is_recognized, ok.out_of_range) == (D("106311.46"), True, False) + big = build_value(Cell("1E+17", D("123456789012345678"), 100.0, "Баланс!D28"), 3, EXCEL, rules) + assert (big.value.amount, big.value.string_value, big.value.is_recognized, big.out_of_range) == ( + None, "1E+17", False, True, + ) + + +def test_build_value_date_missing_multiline_split(rules: Rules) -> None: + as_date = build_value(Cell("30.09.2025", date(2025, 9, 30), 100.0, "Баланс!D3"), 3, EXCEL, rules).value + assert (as_date.amount, as_date.string_value, as_date.is_recognized) == (None, "30.09.2025", False) + missing = build_value(None, 4, OCR, rules).value + assert (missing.amount, missing.string_value, missing.text, missing.is_recognized) == (None, None, None, True) + assert missing.confidence == pytest.approx(100.0) + multi = build_value(Cell("917 775\n2 371 333", None, 100.0, "row:9"), 2, OCR, rules).value + assert (multi.amount, multi.string_value, multi.is_recognized) == (None, "917 775\n2 371 333", False) + part = build_value(Cell("917 775", None, 100.0, "row:9#1"), 2, OCR, rules, split_part=True).value + assert (part.amount, part.is_recognized) == (D("917775"), False) + dash = build_value(Cell("—", None, 100.0, "row:3"), 2, OCR, rules).value + assert (dash.amount, dash.string_value, dash.is_recognized, dash.text) == (None, None, True, "—") +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_amounts.py -v` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement `amounts.py`.** Стадии по спеке; уточнения, которые спека оставляет: «лучшая догадка» при + пробеле не по группам и смешанные разделители — цифры подряд (не длиннее 16, иначе `amount=None`, + `string_value=text`); текст без пробелов с более чем 16 целыми цифрами — `amount=None`, `string_value=text`, + `is_recognized=False` (без предупреждения, P1-10). Пробелы — `\s`, NBSP ` `, тонкий ` `, ` `. + Удаление разделителей тысяч по шаблону исправлением не считается. + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: parse_amount по стадиям (ядро числа, краевой мусор, скобки, разделители) и значение ячейки" +``` + +--- + +### Task 7: Шапка кандидата и тип-подсказка + +**Files:** +- Create: `$R/arm_statements/header.py` +- Test: `$R/arm_statements/tests/test_header.py` + +**Interfaces:** +- Consumes: `HeaderCells`, `StatementHeader`, `HeaderField`, `DateField`, `parse_date`, `fix_lookalikes`, `Rules`. +- Produces (`arm_statements/header.py`): + - `parse_header(cells: HeaderCells, rules: Rules) -> StatementHeader` — у каждого поля `text = cell.text`, + `confidence = cell.confidence` (нет ячейки — `None`, `None`, `None`); `inn` — только цифры текста **без** замены + двойников, длина 10 или 12, иначе `None`; `report_date` — `cell.value`, если это `date`, иначе + `parse_date(fix_lookalikes(text))`; `units` — первое совпадение `units.spellings` по `normalize_caption(text)`, иначе + по `okei` разобранному; `okei`, `okpo`, `okopf`, `okfs` — только цифры (пусто → `None`); `okud` — первое совпадение + `headers.okud_code`; `organization_name`, `address` — текст без пробелов по краям (пусто → `None`); + `needs_review_fields = ()` + - `type_hint(okud_text: str | None, title_text: str | None, rules: Rules) -> DocumentType | None` — ОКУД по словарю + `headers.okud` → иначе заголовок по `headers.form_titles` (оба — `search` по `normalize_label(text)`) → иначе `None` + - `is_continuation(text: str | None, rules: Rules) -> bool` — текст — маркер продолжения «Форма 0710001 с.2»: хотя + бы один шаблон `headers.continuation` находится в `normalize_label(text)` (для адаптера Excel плана 4: маркер без + ОКУД из словаря — всё равно маркер) + - `continuation_type(text: str | None, rules: Rules) -> DocumentType | None` — `is_continuation` → тип по ОКУД из + маркера (`headers.okud`); не маркер **или** маркер без известного ОКУД — `None` (для адаптера Excel плана 4) + +- [ ] **Step 1: Write the failing test** + +```python +from datetime import date + +from arm_statements.header import continuation_type, is_continuation, parse_header, type_hint +from arm_statements.loader import Rules +from arm_statements.model import DocumentType +from arm_statements.tests.builders import hdr + + +def test_inn_digits_only_without_lookalikes(rules: Rules) -> None: + head = parse_header(hdr(inn="|7700000000|"), rules) + assert head.inn.value == "7700000000" + dirty = parse_header(hdr(inn=("3|1 21 2|", 80.0)), rules) + assert dirty.inn.value is None + assert (dirty.inn.text, dirty.inn.confidence) == ("3|1 21 2|", 80.0) + + +def test_units_by_text_then_okei(rules: Rules) -> None: + assert parse_header(hdr(units="в тыс. рублей"), rules).units.value == "тыс. руб." + by_okei = parse_header(hdr(okei="384"), rules) + assert (by_okei.units.value, by_okei.okei.value, by_okei.units.text) == ("тыс. руб.", "384", None) + assert parse_header(hdr(units="руб."), rules).units.value == "руб." + + +def test_report_date_and_codes(rules: Rules) -> None: + head = parse_header(hdr(report_date="30.09.2О25", okud="Форма по ОКУД 0710001", okopf="12267"), rules) + assert head.report_date.value == date(2025, 9, 30) + assert (head.okud.value, head.okopf.value) == ("0710001", "12267") + + +def test_type_hint_okud_wins(rules: Rules) -> None: + assert type_hint("0710002", "Бухгалтерский баланс", rules) is DocumentType.INCOME + assert type_hint("Форма по ОКУД 0710001", None, rules) is DocumentType.BALANCE + assert type_hint("0710005", None, rules) is DocumentType.DISCLOSURE + assert type_hint(None, "Пояснения к бухгалтерскому балансу", rules) is DocumentType.DISCLOSURE + assert type_hint(None, "Отчет о прибылях и убытках", rules) is DocumentType.INCOME + assert type_hint(None, "Бухгалтерская отчетность", rules) is None + + +def test_continuation_marker(rules: Rules) -> None: + assert continuation_type("Форма 0710002 с.2", rules) is DocumentType.INCOME + assert continuation_type("Бухгалтерский баланс", rules) is None + assert is_continuation("Форма\u00a00710000 с. 3", rules) and continuation_type("Форма 0710000 с. 3", rules) is None + assert not is_continuation("Бухгалтерский баланс", rules) and not is_continuation(None, rules) + + +def test_type_hint_uses_label_normalization(rules: Rules) -> None: + assert type_hint(None, "ОТЧЁТ О ФИНАНСОВЫХ\nРЕЗУЛЬТАТАХ", rules) is DocumentType.INCOME +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_header.py -v` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement `header.py`** по Interfaces. + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: разбор шапки формы (ИНН без двойников, единицы по ОКЕИ) и тип-подсказка по ОКУД и заголовку" +``` + +--- + +### Task 8: Шаг 2 — столбец кодов, нумерация, строка заголовка, роли, наименование + +**Files:** +- Create: `$R/arm_statements/layout.py` +- Test: `$R/arm_statements/tests/test_layout.py` + +**Interfaces:** +- Consumes: `Cell`, `RowRole`, `parse_code`, `is_code_cell`, `section_of`, `majority_section`, `normalize_caption`, + `fix_lookalikes`, `parse_period_label`, `Rules`. +- Produces (`arm_statements/layout.py`; `Grid = Sequence[Sequence[Cell | None]]`, строки `first..last` включительно): + - `cell_at(rows: Grid, row: int, col: int) -> Cell | None` — вне строки → `None` + - `find_code_column(rows: Grid, first: int, last: int, rules: Rules, *, + relaxed_section: CodeSection | None = None) -> int | None` — правило (а)/(б) спеки «Шаг 2 — столбец кодов» + («под подписью» — ячейки столбца ниже строки подписи до `last`); `relaxed_section` задан — ослабленный поиск: + столбец с наибольшим числом кодов этого отрезка, минимум `rules.code_ranges.min_codes_continuation` (ничья — левый + столбец) + - `code_column(rows: Grid, rules: Rules, *, relaxed: DocumentType | None = None) -> int | None` — **публичная** + обёртка для адаптеров: `find_code_column(rows, 0, len(rows) - 1, rules, relaxed_section=…)` над всей сеткой (пустая + сетка — `None`); `relaxed` — `BALANCE` / `INCOME` → отрезок `CodeSection` этого типа, `DISCLOSURE` → `ValueError` + (у пояснений нескольких отрезков). Одно правило с шагом 2: адаптер Excel берёт им вид листа, границу шапки и столбец + для `sheet_joins` + - `first_code_row(rows: Grid, first: int, last: int, code_col: int, rules: Rules) -> int | None` + - `is_numbering_row(cells: Sequence[Cell | None], rules: Rules) -> bool` — ≥ 3 непустых ячеек, каждая после + `fix_lookalikes(anywhere=True)` — целое, слева направо +1, начало 1 или 2, не больше одного пропуска (скачок +2) + - `find_header_rows(rows: Grid, first: int, first_code: int, code_col: int, rules: Rules) -> tuple[int, ...]` — + строка заголовка по спеке «Шаг 2 — строка заголовка…» (строки нумерации пропускаются при поиске «последней над + первой строкой с кодом»); двухстрочный: `(r - 1, r)`, если для какого-то столбца правее кода подпись строки + `r - 1` отдельно — не период, а «подпись `r - 1` + пробел + подпись `r`» — период (P1-9: «На 30 сентября» + + «2025 г.»); не найдена — `()` + - `segment_roles(rows: Grid, first: int, last: int, header_rows: tuple[int, ...], rules: Rules) -> list[RowRole]` — + выше заголовка `TITLE`, строки заголовка `HEADER`, нумерация `NUMBERING`, остальное `DATA`; без заголовка `TITLE` нет + - `find_name_column(rows: Grid, header_rows: tuple[int, ...], first: int, last: int, code_col: int, + rules: Rules) -> tuple[int | None, int | None]` — `(столбец наименования, столбец «Пояснения»)` по спеке «Шаг 2 — + столбец наименования»; «Пояснения» — подпись `captions.explanations`, а без подписи — столбец левее кода, где все + непустые значения не длиннее 3 символов + - `name_cell(cells: Sequence[Cell | None], name_col: int | None, code_col: int, explanations_col: int | None) -> + Cell | None` — ячейка столбца наименования; пустая — ближайшая левее кода ячейка с буквами, кроме «Пояснений» + +- [ ] **Step 1: Write the failing test** + +```python +from decimal import Decimal as D + +import pytest + +from arm_statements.layout import ( + code_column, find_code_column, find_header_rows, find_name_column, first_code_row, is_numbering_row, segment_roles, +) +from arm_statements.loader import CodeSection, Rules +from arm_statements.model import Cell, DocumentType, RowRole +from arm_statements.tests.builders import excel, ocr, place + +BALANCE_ROWS = [ + ["Наименование показателя", "Код", "На 30.09.2025", "На 31.12.2024"], + ["1", "2", "3", "4"], + ["Нематериальные активы", "1110", "10", "9"], + ["Основные средства", "1150", "20", "19"], + ["Итого по разделу I", "1100", "30", "28"], +] + + +def test_code_column_by_caption(rules: Rules) -> None: + cand = ocr(BALANCE_ROWS) + assert (find_code_column(cand.rows, 0, 4, rules), first_code_row(cand.rows, 0, 4, 1, rules)) == (1, 2) + + +def test_title_block_kody_is_not_code_column(rules: Rules) -> None: + title = [place({5: v}) for v in ["Коды", "0710001", "30 09 2025", "12345678", "7700000000", "12267", "16", "384", + "", ""]] + table = [place({0: n, 2: c, 3: D(a)}) for n, c, a in + [("Запасы", "1210", "5"), ("НДС", "1220", "1"), ("Дебиторы", "1230", "4"), ("Итого", "1200", "10")]] + cand = excel([("Баланс", [*title, *table])]) + assert [find_code_column(cand.rows, 0, len(cand.rows) - 1, rules)] == [2] + + +def test_two_line_document(rules: Rules) -> None: + cand = ocr([["Наименование", "Код", "2025"], ["Выручка", "2110", "5"], ["Прибыль", "2400", "1"]]) + assert find_code_column(cand.rows, 0, 2, rules) == 1 + + +def test_relaxed_search_finds_single_code(rules: Rules) -> None: + cand = ocr([["Чистая прибыль", "", "2400", "7", "6"]]) + assert find_code_column(cand.rows, 0, 0, rules) is None + assert [find_code_column(cand.rows, 0, 0, rules, relaxed_section=CodeSection.INCOME)] == [2] + + +def test_public_code_column(rules: Rules) -> None: + assert code_column(ocr(BALANCE_ROWS).rows, rules) == 1 + single = ocr([["Чистая прибыль", "", "2400", "7", "6"]]).rows + assert (code_column(single, rules), code_column(single, rules, relaxed=DocumentType.INCOME)) == (None, 2) + assert code_column(single, rules, relaxed=DocumentType.BALANCE) is None and code_column((), rules) is None + with pytest.raises(ValueError, match="DISCLOSURE"): + code_column(single, rules, relaxed=DocumentType.DISCLOSURE) + + +def test_numbering_rows(rules: Rules) -> None: + def row(*texts: str) -> tuple[Cell | None, ...]: + return ocr([list(texts)]).rows[0] + + assert is_numbering_row(row("1", "2", "3", "4", "5"), rules) + assert is_numbering_row(row("1", "2", "З", "4"), rules) + assert is_numbering_row(row("2", "3", "4", "5"), rules) + assert is_numbering_row(row("1", "2", "4", "5"), rules) + assert not is_numbering_row(row("1", "3", "5"), rules) + assert not is_numbering_row(row("1", "2"), rules) + + +def test_header_row_roles_and_title(rules: Rules) -> None: + rows = [["Бухгалтерский баланс", "", "", ""], *BALANCE_ROWS] + cand = ocr(rows) + header = find_header_rows(cand.rows, 0, 3, 1, rules) + assert header == (1,) + roles = segment_roles(cand.rows, 0, 5, header, rules) + assert roles == [RowRole.TITLE, RowRole.HEADER, RowRole.NUMBERING, RowRole.DATA, RowRole.DATA, RowRole.DATA] + assert RowRole.TITLE not in segment_roles(cand.rows, 3, 5, (), rules) + + +def test_two_row_header_is_glued(rules: Rules) -> None: + cand = ocr([["", "", "На 30 сентября", "На 31 декабря"], ["Наименование", "Код", "2025 г.", "2024 г."], + ["Запасы", "1210", "5", "4"]]) + assert find_header_rows(cand.rows, 0, 2, 1, rules) == (0, 1) + single = ocr([["Раздел", "", "", ""], ["Наименование", "Код", "На 30.09.2025", "На 31.12.2024"], + ["Запасы", "1210", "5", "4"]]) + assert find_header_rows(single.rows, 0, 2, 1, rules) == (1,) + + +def test_name_column_without_caption(rules: Rules) -> None: + cand = ocr([["5.1", "Запасы", "1210", "5"], ["5.2", "Дебиторская задолженность", "1230", "4"], + ["", "Итого", "1200", "9"]]) + assert find_name_column(cand.rows, (), 0, 2, 2, rules) == (1, 0) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_layout.py -v` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement `layout.py`** по Interfaces (доноры: tabular-parser `columns.py` — `find_code_column`, + `_find_header_row`, `_upper_header_row`, `_name_column`, `_note_columns`; scan-gateway `rows.py::is_numbering_row`, + `layout.py`). Ячейка-код для подсчёта — `is_code_cell`; «непустая» — `text.strip() != ""`. + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: шаг 2 — столбец кодов с защитой от блока «Коды», нумерация, строка заголовка, роли строк" +``` + +--- + +### Task 9: Шаг 2 — колонки значений, подписи и даты + +**Files:** +- Create: `$R/arm_statements/columns.py` +- Test: `$R/arm_statements/tests/test_columns.py` + +**Interfaces:** +- Consumes: `layout.py` (`cell_at`, `find_header_rows`), `parse_period_label`, `resolve_relative`, `expected_dates`, + `parse_amount`, `make_warning`, `StatementHeader`, `StatementColumn`, `Rules`. +- Produces (`arm_statements/columns.py`): + - `ColumnDraft(grid_col: int, label: str, label_confidence: float | None, kind: PeriodKind, date: date | None)` — + колонка сегмента до сопоставления; `date` абсолютной подписи (у относительной `None`) + - `has_numbers(rows: Grid, data_rows: Sequence[int], col: int, code_col: int, source: Source, rules: Rules) -> bool` — + в строках с кодом есть ячейка с `value`-числом или с `parse_amount(...).amount is not None` + - `amount_columns(rows: Grid, code_col: int, rules: Rules, *, source: Source = Source.EXCEL) -> tuple[int, ...]` — + **публичная**: столбцы правее `code_col` по возрастанию, где `has_numbers` по строкам сетки с ячейкой-кодом + (`is_code_cell`) в `code_col`; то же правило, что колонки сегмента без заголовка (`segment_columns` берёт его же) и + «число колонок сумм» для `sheet_joins` (`len(...)`) + - `segment_columns(rows: Grid, header_rows: tuple[int, ...], data_rows: Sequence[int], code_col: int, + source: Source, rules: Rules) -> tuple[list[ColumnDraft], list[StatementWarning]]` — сегмент с заголовком: столбцы + правее кода; подпись (склейка строк заголовка через пробел; уверенность — минимум) → период — колонка; исключение + — пропуск и `COLUMN_SKIPPED` с `column_index = grid_col`; иначе при числах — колонка с подписью как есть, `NONE`, + `COLUMN_WITHOUT_DATE` (P1-17: пустая подпись при числах — так же); без чисел — молча пропуск. Сегмент без заголовка + (`header_rows == ()`): столбцы правее кода с числами, `label=""`, `label_confidence=None`, `NONE`, без предупреждений + - `DocumentDates(effective: date | None, confident_effective: date | None, latest_absolute: date | None, + confident_latest_absolute: date | None)` + - `document_dates(drafts: Sequence[ColumnDraft], header: StatementHeader) -> DocumentDates` — `effective`: дата + отчёта, иначе самая поздняя абсолютная; `confident_effective`: дата отчёта с уверенностью 100, иначе самая поздняя + абсолютная с `label_confidence == 100`; `latest_absolute` и `confident_latest_absolute` — самые поздние абсолютные + (все / с уверенной подписью) + - `finalize_columns(drafts: Sequence[ColumnDraft], dates: DocumentDates, rules: Rules) -> + tuple[tuple[StatementColumn, ...], list[StatementWarning]]` — `column_index = grid_col`; относительные → + `resolve_relative(kind, dates.effective)`; абсолютная подпись с `label_confidence < 100` не в + `expected_dates(dates.confident_effective)` (или `confident_effective is None`) → `COLUMN_DATE_UNCERTAIN` с + `column_index` (только абсолютные, P1-11) + +- [ ] **Step 1: Write the failing test** + +```python +from datetime import date +from decimal import Decimal as D + +from arm_statements.columns import amount_columns, document_dates, finalize_columns, segment_columns +from arm_statements.header import parse_header +from arm_statements.loader import Rules +from arm_statements.model import PeriodKind, Severity, Source +from arm_statements.tests.builders import excel, hdr, ocr + +OCR, EXCEL = Source.OCR, Source.EXCEL + + +def run(rows: list[list[object]], source: Source, rules: Rules, header=None, header_rows=(0,), data_rows=(1, 2)): + cand = ocr(rows) if source is OCR else excel([("Лист1", rows)]) + drafts, warnings = segment_columns(cand.rows, header_rows, data_rows, 1 if source is OCR else 2, source, rules) + head = parse_header(header or hdr(), rules) + columns, more = finalize_columns(drafts, document_dates(drafts, head), rules) + return columns, [*warnings, *more] + + +def test_blank_66n_relative_labels(rules: Rules) -> None: + rows = [["Наименование", "Код", "На отчетную дату", "На 31 декабря предыдущего года", + "На 31 декабря года, предшествующего предыдущему"], + ["Запасы", "1210", "5", "4", "3"], ["Итого", "1200", "5", "4", "3"]] + columns, warnings = run(rows, OCR, rules, header=hdr(report_date="30.09.2025")) + assert [c.period_date for c in columns] == [date(2025, 9, 30), date(2024, 12, 31), date(2023, 12, 31)] + assert [c.period_kind for c in columns] == [PeriodKind.CURRENT, PeriodKind.PREV_YEAR_END, + PeriodKind.PREV_PREV_YEAR_END] + assert warnings == [] + + +def test_excel_skipped_change_column(rules: Rules) -> None: + rows = [["Наименование", "", "Код", "На 30.09.2025", "На 31.12.2024", "Изменение, %"], + ["Запасы", "", "1210", D("5"), D("4"), D("25")], ["Итого", "", "1200", D("5"), D("4"), D("25")]] + columns, warnings = run(rows, EXCEL, rules) + assert [c.column_index for c in columns] == [3, 4] + assert [(w.code, w.severity, w.column_index) for w in warnings] == [("COLUMN_SKIPPED", Severity.INFO, 5)] + assert {c.label_confidence for c in columns} == {100.0} + + +def test_junk_label_over_numbers_is_kept(rules: Rules) -> None: + rows = [["Наименование", "Код", "На 30.09.2025", ("| | г", 17.0)], + ["Запасы", "1210", "5", "4"], ["Итого", "1200", "5", "4"]] + columns, warnings = run(rows, OCR, rules, header=hdr(report_date="30.09.2025")) + junk = columns[1] + assert (junk.label, junk.period_date, junk.period_kind) == ("| | г", None, PeriodKind.NONE) + assert [(w.code, w.severity, w.column_index) for w in warnings] == [("COLUMN_WITHOUT_DATE", Severity.ERROR, 3)] + + +def test_sparse_ocr_columns(rules: Rules) -> None: + rows = [["Наименование", "Код", "На 30.09.2025", None, "На 31.12.2024", "На 31.12.2023"], + ["Запасы", "1210", "5", None, "4", "3"], ["Итого", "1200", "5", None, "4", "3"]] + columns, _ = run(rows, OCR, rules) + assert [c.column_index for c in columns] == [2, 4, 5] + + +def test_uncertain_label_does_not_confirm_itself(rules: Rules) -> None: + rows = [["Наименование", "Код", ("На 30.09.2О25", 60.0), ""], ["Запасы", "1210", "5", "4"], + ["Итого", "1200", "5", "4"]] + cand = ocr(rows) + drafts, _ = segment_columns(cand.rows, (0,), (1, 2), 1, OCR, rules) + dates = document_dates(drafts, parse_header(hdr(), rules)) + assert (dates.effective, dates.confident_effective) == (date(2025, 9, 30), None) + _, warnings = finalize_columns(drafts, dates, rules) + assert [(w.code, w.column_index) for w in warnings] == [("COLUMN_DATE_UNCERTAIN", 2)] + + +def test_uncertain_label_confirmed_by_report_date(rules: Rules) -> None: + rows = [["Наименование", "Код", "На 30.09.2025", ("На 31.12.2024", 60.0), ("На 31.03.2025", 60.0)], + ["Запасы", "1210", "5", "4", "3"], ["Итого", "1200", "5", "4", "3"]] + _, warnings = run(rows, OCR, rules, header=hdr(report_date="30.09.2025")) + assert [(w.code, w.column_index) for w in warnings] == [("COLUMN_DATE_UNCERTAIN", 4)] + + +def test_segment_without_header(rules: Rules) -> None: + cand = ocr([["Запасы", "1210", "5", "", "4"], ["Итого", "1200", "5", "", "4"]]) + drafts, warnings = segment_columns(cand.rows, (), (0, 1), 1, OCR, rules) + assert [(d.grid_col, d.label, d.kind) for d in drafts] == [(2, "", PeriodKind.NONE), (4, "", PeriodKind.NONE)] + assert warnings == [] + + +def test_public_amount_columns(rules: Rules) -> None: + rows = [["Наименование", "", "Код", "На 30.09.2025", "На 31.12.2024", "Изменение"], + ["Запасы", "", "1210", D("5"), D("4"), "рост"], ["Итого", "", "1200", D("5"), "", "рост"]] + assert amount_columns(excel([("Лист1", rows)]).rows, 2, rules) == (3, 4) + assert amount_columns(ocr([["Запасы", "1210", "5", "", "4"]]).rows, 1, rules, source=OCR) == (2, 4) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_columns.py -v` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement `columns.py`** по Interfaces (донор — tabular-parser `columns.py::_period_columns`, + `_value_columns`, `_joined_label`; scan-gateway `document.py::_columns`). + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: шаг 2 — колонки периодов, относительные даты бланка 66н, неуверенные и мусорные подписи" +``` + +--- + +### Task 10: Шаг 2 — сегменты, сопоставление колонок, `find_layout` + +**Files:** +- Create: `$R/arm_statements/segments.py` +- Test: `$R/arm_statements/tests/test_segments.py` + +**Interfaces:** +- Consumes: `layout.py`, `columns.py`, `majority_section`, `make_warning`, `FormCandidate`, `StatementHeader`. +- Produces (`arm_statements/segments.py`): + - `SegmentLayout(first_row: int, last_row: int, header_rows: tuple[int, ...], code_col: int | None, + name_col: int | None, explanations_col: int | None, value_cols: tuple[tuple[int, int], ...], + extra_cols: tuple[int, ...])` — `value_cols`: `(столбец сетки сегмента, column_index колонки документа)` + - `Layout(code_found: bool, column_section: CodeSection | None, columns: tuple[StatementColumn, ...], + segments: tuple[SegmentLayout, ...], roles: tuple[RowRole, ...], dates: DocumentDates, + warnings: tuple[StatementWarning, ...])` — `roles` — по всем строкам сетки; `column_section` — отрезок большинства + кодов столбца кодов первого сегмента с найденным столбцом + - `find_layout(candidate: FormCandidate, header: StatementHeader, rules: Rules) -> Layout` + +Правила `find_layout` (спека «Шаг 2 — сегменты, повторная шапка и границы листов», «строка заголовка…», «номер +колонки»): сетка делится по `segment_breaks`; внутри куска новый сегмент открывает строка-шапка, стоящая **после** +первой строки с кодом текущего сегмента (ячейка подписи кода или ≥ 2 непустых ячеек, равных после `normalize_caption` +ячейкам строк заголовка первого сегмента). Раскладка сегмента: с заголовком — столбец кодов (а)/(б) и колонки по +заголовку; без заголовка — (б), а не первому сегменту при кодах < 3 — ослабленный поиск по `column_section`. Колонки +документа — колонки первого сегмента; сегмент со своей шапкой сопоставляется по `period_date` (у каждой колонки дата +есть и совпадает ровно с одной колонкой первого), иначе по порядку; без шапки — по порядку. По порядку и разное число — +`SEGMENT_COLUMNS_MISMATCH` (значения в пределах меньшего числа, `extra_cols` — лишние, их номера в `message`). Сегмент +с содержимым без столбца кодов — `SEGMENT_WITHOUT_CODES`, его строки — `DATA`. Предупреждения о подписях +(`COLUMN_SKIPPED`, `COLUMN_WITHOUT_DATE`, `COLUMN_DATE_UNCERTAIN`) выдаются только по колонкам документа (сегмента, +задающего колонки); у остальных сегментов столбцы-исключения пропускаются молча, неразобранные подписи не +предупреждают (сценарий «Повторная шапка с другими подписями» — предупреждений нет). Заголовка нет ни у одного сегмента — +колонки первого сегмента по правилу «без заголовка» и одно `HEADER_ROW_NOT_FOUND`; заголовок есть хоть у одного, а у +первого нет — колонки документа берутся из первого сегмента с заголовком (P1-18). `code_found` — столбец кодов найден +хотя бы у одного сегмента. + +- [ ] **Step 1: Write the failing test** + +```python +from decimal import Decimal as D + +from arm_statements.header import parse_header +from arm_statements.loader import Rules +from arm_statements.model import RowRole +from arm_statements.segments import find_layout +from arm_statements.tests.builders import excel, hdr, ocr, place + +DATES3 = ["На 30 сентября 2025 г.", "На 31 декабря 2024 г.", "На 31 декабря 2023 г."] + + + +def test_npz_sheet_with_own_header_maps_by_date(rules: Rules) -> None: + first = [place({1: "Наименование показателя", 12: "Код", 16: DATES3[0], 21: DATES3[1], 24: DATES3[2]}), + place({1: "Нематериальные активы", 12: "1110", 16: D("10"), 21: D("9"), 24: D("8")}), + place({1: "Итого по разделу I", 12: "1100", 16: D("10"), 21: D("9"), 24: D("8")})] + sheet4 = [place({0: "Наименование показателя", 6: "Код", 8: DATES3[0], 9: DATES3[1], 10: DATES3[2]}), + place({0: "Заемные средства", 6: "1510", 8: D("3"), 9: D("2"), 10: D("1")})] + cand = excel([("Бухгалтерский баланс", first), ("Бухгалтерский баланс. Лист 4", sheet4)]) + layout = find_layout(cand, parse_header(hdr(report_date="30.09.2025"), rules), rules) + assert [c.column_index for c in layout.columns] == [16, 21, 24] + assert layout.segments[1].value_cols == ((8, 16), (9, 21), (10, 24)) + assert not [w for w in layout.warnings if w.code.startswith("SEGMENT_")] + + +def test_repeat_header_mid_table(rules: Rules) -> None: + rows = [["Наименование показателя", "Код", "На 30.09.2025", "На 31.12.2024"], ["Запасы", "1210", "5", "4"], + ["Наименование показателя", "Код", "На 30.09.2025", "На 31.12.2024"], ["Итого", "1200", "5", "4"]] + layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules) + assert [s.first_row for s in layout.segments] == [0, 2] + assert layout.roles[2] is RowRole.HEADER + + +def test_repeat_header_without_dates_maps_by_order(rules: Rules) -> None: + rows = [["Наименование", "Код", "На 30.09.2025", "На 31.12.2024"], ["Запасы", "1210", "5", "4"], + ["Наименование", "Код", "отчетн", "прошл"], ["Итого", "1200", "5", "4"]] + layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules) + assert layout.segments[1].value_cols == ((2, 2), (3, 3)) + assert layout.warnings == () + + +def test_wider_segment_is_mismatch(rules: Rules) -> None: + rows = [["Наименование", "Код", "На 30.09.2025", "На 31.12.2024", "На 31.12.2023"], + ["Запасы", "1210", "5", "4", "3"], + ["Наименование", "Код", "графа 3", "графа 4", "графа 5", "графа 6"], + ["Итого", "1200", "5", "4", "3", "2"]] + layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules) + (warning,) = [w for w in layout.warnings if w.code == "SEGMENT_COLUMNS_MISMATCH"] + assert warning.severity.value == "error" and "5" in warning.message + assert layout.segments[1].value_cols == ((2, 2), (3, 3), (4, 4)) and layout.segments[1].extra_cols == (5,) + + +def test_second_sheet_single_code_in_other_column(rules: Rules) -> None: + first = [place({0: "Наименование", 3: "Код", 4: "За 9 месяцев 2025 г.", 5: "За 9 месяцев 2024 г."}), + place({0: "Выручка", 3: "2110", 4: D("100"), 5: D("90")}), + place({0: "Себестоимость", 3: "2120", 4: D("-60"), 5: D("-50")}), + place({0: "Валовая прибыль", 3: "2100", 4: D("40"), 5: D("40")})] + second = [place({0: "Чистая прибыль", 1: "2400", 2: D("7"), 3: D("6")})] + cand = excel([("ОФР", first), ("ОФР_2", second)]) + layout = find_layout(cand, parse_header(hdr(), rules), rules) + assert layout.segments[1].code_col == 1 + assert layout.segments[1].value_cols == ((2, 4), (3, 5)) + assert not [w for w in layout.warnings if w.code.startswith(("SEGMENT_", "HEADER_"))] + + +def test_header_not_found_anywhere(rules: Rules) -> None: + rows = [["Запасы", "1210", "5", "4"], ["Дебиторы", "1230", "3", "2"], ["Итого", "1200", "8", "6"]] + layout = find_layout(ocr(rows), parse_header(hdr(), rules), rules) + assert [w.code for w in layout.warnings] == ["HEADER_ROW_NOT_FOUND"] + assert [(c.column_index, c.label) for c in layout.columns] == [(2, ""), (3, "")] + + +def test_segment_without_codes(rules: Rules) -> None: + first = [["Наименование", "Код", "На 30.09.2025"], ["Запасы", "1210", "5"], ["Итого", "1200", "5"]] + tail = [["Примечание к форме", "", "7"]] + layout = find_layout(excel([("Баланс", first), ("Хвост", tail)]), parse_header(hdr(), rules), rules) + assert [w.code for w in layout.warnings] == ["SEGMENT_WITHOUT_CODES"] + assert layout.roles[3] is RowRole.DATA and layout.code_found +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_segments.py -v` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement `segments.py`** по Interfaces и правилам выше (донор — tabular-parser `merge.py`, логика + `relaxed` в `columns.py::find_layout`). + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: шаг 2 — сегменты, повторная шапка, листы-продолжения и сопоставление колонок с первым сегментом" +``` + +--- + +### Task 11: Шаг 3 — строки данных, статусы и разлепление + +**Files:** +- Create: `$R/arm_statements/rows.py` +- Test: `$R/arm_statements/tests/test_rows.py` + +**Interfaces:** +- Consumes: `Layout`, `SegmentLayout`, `cell_at`, `name_cell`, `parse_code`, `code_parts`, `section_of`, `has_digit`, + `is_disguised_digit`, `parse_amount`, `make_warning`, `FormCandidate`, `LineStatus`. +- Produces (`arm_statements/rows.py`): + - `RawLine(row_index: int, ref: str, grid_row: int, code_cell: Cell | None, code: str | None, code_fixed: bool, + line_status: LineStatus, name: Cell | None, parent_ref: str | None, cells: tuple[tuple[int, Cell | None], ...], + split: bool)` — `cells` — по **всем** колонкам документа по порядку `layout.columns` (`column_index`, ячейка сегмента + или `None`); у разлепленных строк `code_cell`, `name` и ячейки значений — производные `Cell` с k-й частью текста, + исходной уверенностью и `ref` `#` + - `collect_raw_lines(candidate: FormCandidate, layout: Layout, rules: Rules) -> + tuple[list[RawLine], list[StatementWarning]]` — по спеке «Шаг 3 — строки данных и их статусы» и «Шаг 3 — + разлепление»: строки роли `DATA` с содержимым; `ref` строки — `row:` / `row:#` у OCR (`` — из `Cell.ref` + ячейки строки), у Excel — `ref` ячейки кода, без неё — первой непустой ячейки строки (у разлепленной — `#`); + `row_index` — порядковый с 0 после разлепления; `parent_ref` у `DETAIL` — ближайшая `OK` выше во всём документе; + `ROW_SPLIT` (`info`) на каждую разлепленную строку сетки с `line_refs` её частей. Сегмент без столбца кодов: ячейка + кода — `None`, значения — по столбцам сегмента `value_cols` (если есть), иначе строки идут без значений (статус — + `detail`/`missing` по наличию содержимого в любой ячейке правее наименования) + +- [ ] **Step 1: Write the failing test** + +```python +from arm_statements.header import parse_header +from arm_statements.loader import Rules +from arm_statements.model import LineStatus +from arm_statements.rows import collect_raw_lines +from arm_statements.segments import find_layout +from arm_statements.tests.builders import excel, hdr, ocr + +HEAD = ["Наименование показателя", "Код", "На 30.09.2025", "На 31.12.2024"] +OK, UNREADABLE, DETAIL, MISSING = LineStatus.OK, LineStatus.UNREADABLE, LineStatus.DETAIL, LineStatus.MISSING + + +def lines_of(rows, rules: Rules, source: str = "ocr"): + cand = ocr(rows) if source == "ocr" else excel([("Баланс", rows)]) + layout = find_layout(cand, parse_header(hdr(), rules), rules) + return collect_raw_lines(cand, layout, rules) + + +def test_junk_code_lines_survive(rules: Rules) -> None: + lines, _ = lines_of([HEAD, ["НМА", "1110", "5", "4"], ["Запасы", "1150", "5", "4"], ["Мусор", "ИЧ,250", "7", "6"], + ["в том числе: прочее", "", "1", ""], ["", "44 30\nI |", "", ""], ["Итого", "1100", "10", "8"]], + rules) + assert [(ln.line_status, ln.code) for ln in lines] == [(OK, "1110"), (OK, "1150"), (UNREADABLE, None), + (DETAIL, None), (UNREADABLE, None), (OK, "1100")] + assert lines[3].parent_ref == lines[1].ref == "row:2" + assert [ln.row_index for ln in lines] == [0, 1, 2, 3, 4, 5] + + +def test_lookalike_only_code_cells_are_empty(rules: Rules) -> None: + lines, _ = lines_of([HEAD, ["НМА", "1110", "5", "4"], ["Запасы", "1150", "5", "4"], ["", "изо :", ("3", 0.0), ""], + ["", "НИ", "I", ""], ["", "| 140", "2", "1"], ["Итого", "1100", "12", "9"]], rules) + assert [(ln.line_status, ln.code, ln.code_fixed) for ln in lines] == [ + (OK, "1110", False), (OK, "1150", False), (DETAIL, None, False), (DETAIL, None, False), (OK, "1140", True), + (OK, "1100", False), + ] + + +def test_missing_before_first_code_and_name_only_row(rules: Rules) -> None: + lines, _ = lines_of([HEAD, ["Выручка", "", "100", "90"], ["Раздел I. Активы", "", "", ""], + ["Запасы", "1150", "5", "4"]], rules) + assert [ln.line_status for ln in lines] == [MISSING, OK] + + +def test_duplicate_code_both_ok(rules: Rules) -> None: + lines, _ = lines_of([HEAD, ["Налог", "2410", "100", "1"], ["Налог УСН", "2410", "649", "2"]], rules) + assert [(ln.line_status, ln.code) for ln in lines] == [(OK, "2410"), (OK, "2410")] + + +def test_split_glued_rows(rules: Rules) -> None: + lines, warnings = lines_of([HEAD, ["Запасы", "1150", "5", "4"], + ["Займы\nКредиторы", "1500\n1700", "917 775\n2 371 333", "1\n2"]], rules) + split = lines[1:] + assert [(ln.ref, ln.code, ln.split) for ln in split] == [("row:2#1", "1500", True), ("row:2#2", "1700", True)] + assert [ln.cells[0][1].text for ln in split if ln.cells[0][1] is not None] == ["917 775", "2 371 333"] + assert [ln.name.text for ln in split if ln.name is not None] == ["Займы", "Кредиторы"] + assert [(w.code, w.line_refs) for w in warnings] == [("ROW_SPLIT", ("row:2#1", "row:2#2"))] + + +def test_not_split_when_no_part_in_column_range(rules: Rules) -> None: + lines, warnings = lines_of([HEAD, ["НМА", "1110", "5", "4"], ["Запасы", "1150", "5", "4"], + ["", "44 30\nI |", "1\n2", ""], ["Итого", "1100", "10", "8"]], rules) + assert [ln.line_status for ln in lines] == [OK, OK, UNREADABLE, OK] and warnings == [] + + +def test_excel_signature_text_is_not_content(rules: Rules) -> None: + rows = [["Наименование", "", "Код", "На 30.09.2025"], ["Запасы", "", "1150", "5"], + ["Руководитель", "", "", "Косарев"], ["", "", "", "(расшифровка подписи)"]] + lines, _ = lines_of(rows, rules, source="excel") + assert [ln.ref for ln in lines] == ["Баланс!C2"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_rows.py -v` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement `rows.py`.** Содержимое ячейки значения: `parse_amount(text, source).amount is not None`, или + непуста с уверенностью < 100, или `is_disguised_digit`; у ячейки с `value`-числом — содержимое всегда. Разлепление — + до разбора кода; «часть — код диапазона столбца» — `parse_code(part).code` и `section_of == layout.column_section`. + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: шаг 3 — строки данных со статусами ok/unreadable/detail/missing, ref, разлепление склеек" +``` + +--- + +### Task 12: Шаги 4–5 — строки формы и предупреждения единиц + +**Files:** +- Create: `$R/arm_statements/lines.py` +- Test: `$R/arm_statements/tests/test_lines.py` + +**Interfaces:** +- Consumes: `RawLine`, `build_value`, `make_warning`, `StatementLine`, `StatementHeader`, `Source`, `Rules`. +- Produces (`arm_statements/lines.py`): + - `CODE_CONFIDENCE_CAP = 99.0` + - `build_lines(raw: Sequence[RawLine], source: Source, rules: Rules) -> + tuple[list[StatementLine], list[StatementWarning]]` — `line_code` только у `OK`; `line_code_text` — + `code_cell.text`; `line_code_confidence` — уверенность ячейки кода, при `code_fixed` — `min(уверенность, 99.0)`; + `line_name` / `line_name_confidence` — текст ячейки наименования как есть / её уверенность; `needs_review = False` + (ставит шаг 8); `values` — `build_value(cell, column_index, source, split_part=raw.split)` по всем колонкам; + `VALUE_OUT_OF_RANGE` (`info`) на каждое значение вне диапазона с `line_refs=(ref,)` и `column_index` + - `units_warnings(lines: Sequence[StatementLine], header: StatementHeader, adapter_warnings: + Sequence[StatementWarning], rules: Rules) -> list[StatementWarning]` — единицы `units.thousands` и доля значений с + дробной частью среди значений с `amount is not None` ≥ `fractional_share_percent` → `XLSB_SCALE_SUSPECT`, если среди + `adapter_warnings` есть `XLSB_NO_FORMATS` (P1-5), иначе `FRACTIONAL_IN_THOUSANDS` + +- [ ] **Step 1: Write the failing test** + +```python +from decimal import Decimal as D + +from arm_statements.header import parse_header +from arm_statements.issues import make_warning +from arm_statements.lines import build_lines, units_warnings +from arm_statements.loader import Rules +from arm_statements.model import Severity, Source +from arm_statements.rows import collect_raw_lines +from arm_statements.segments import find_layout +from arm_statements.tests.builders import excel, hdr, ocr + +HEAD = ["Наименование показателя", "Код", "На 30.09.2025"] + + +def statement_lines(cand, rules: Rules): + layout = find_layout(cand, parse_header(hdr(), rules), rules) + raw, _ = collect_raw_lines(cand, layout, rules) + return build_lines(raw, cand.source, rules) + + +def test_ocr_name_and_code_confidence(rules: Rules) -> None: + cand = ocr([HEAD, [("Дебиторская задолжен-ность", 71.0), "1230", "5"], ["Итого", "I1О0", "5"]]) + lines, _ = statement_lines(cand, rules) + assert (lines[0].line_name, lines[0].line_name_confidence) == ("Дебиторская задолжен-ность", 71.0) + assert (lines[1].line_code, lines[1].line_code_text, lines[1].line_code_confidence) == ("1100", "I1О0", 99.0) + assert [v.column_index for v in lines[0].values] == [2] + + +def test_value_out_of_range_warning(rules: Rules) -> None: + cand = excel([("Баланс", [["Наименование", "Код", "На 30.09.2025"], + ["Запасы", "1210", D("123456789012345678")]])]) + lines, warnings = statement_lines(cand, rules) + assert lines[0].values[0].amount is None + assert [(w.code, w.severity, w.line_refs, w.column_index) for w in warnings] == [ + ("VALUE_OUT_OF_RANGE", Severity.INFO, ("Баланс!B2",), 2) + ] + + +def test_fractional_in_thousands(rules: Rules) -> None: + rows = [["Наименование", "Код", "На 30.09.2025"], *[[f"Строка {i}", c, D(v)] for i, (c, v) in + enumerate([("1210", "1.5"), ("1220", "2.25"), ("1230", "3"), ("1240", "4"), ("1200", "10.75")])]] + cand = excel([("Баланс", rows)]) + lines, _ = statement_lines(cand, rules) + head = parse_header(hdr(units="тыс. руб."), rules) + assert [w.code for w in units_warnings(lines, head, (), rules)] == ["FRACTIONAL_IN_THOUSANDS"] + xlsb = make_warning("XLSB_NO_FORMATS", "формат чисел xlsb не читается", rules) + (scale,) = units_warnings(lines, head, (xlsb,), rules) + assert (scale.code, scale.severity) == ("XLSB_SCALE_SUSPECT", Severity.ERROR) + assert units_warnings(lines, parse_header(hdr(units="руб."), rules), (), rules) == [] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_lines.py -v` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement `lines.py`** по Interfaces. + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: шаги 4–5 — строки формы с сырым текстом и уверенностью, предупреждения единиц и масштаба xlsb" +``` + +--- + +### Task 13: Шаг 7 — самопроверки §7 и вариант формы + +**Files:** +- Create: `$R/arm_statements/evaluation.py`, `$R/arm_statements/checks.py` +- Test: `$R/arm_statements/tests/test_checks.py` + +**Interfaces:** +- Consumes: `CheckRules`, `RelationRule`, `DocumentType`, `FormVariant`, `LineStatus`, `FailedRelation`, + `StatementChecks`, `section_of`. +- Produces (`arm_statements/evaluation.py`) — общий вид строк шагов 6–8 у сервиса и ядра: + - `EvalValue(column: str, amount: Decimal | None, text: str | None, confidence: float | None, is_recognized: bool, + fixed: bool = False)` — `column` — ключ колонки (`str(column_index)` у сервиса, ключ хоста у ядра); `fixed` — + правлено или подтверждено аналитиком + - `EvalLine(key: str, order: int | None, line_status: LineStatus, line_code: str | None, + code_confidence: float | None, code_fixed: bool, values: tuple[EvalValue, ...])` — `key`: `ref` у сервиса, ключ + хоста у ядра; `order`: `row_index` (у ядра `None` — строка правки) + - `is_check_line(line: EvalLine) -> bool` — `OK` с кодом; у строки `order is None` — ещё и хотя бы одно `amount` не + `None` (P1-8) +- Produces (`arm_statements/checks.py`): + - `form_variant(document_type: DocumentType, ok_codes: Iterable[str]) -> FormVariant | None` — спека «Шаг 6»: ББ — + есть 1600 и нет ни 1100, ни 1200 → `SIMPLIFIED`; ОФР — есть 2400 или 2110 и нет ни 2100, ни 2200, ни 2300 → + `SIMPLIFIED`; иначе `FULL`; `DISCLOSURE` → `None` + - `subline_parents(codes: Sequence[str]) -> list[str | None]` — для каждой позиции: у пятизначного кода — ближайший + четырёхзначный выше с теми же первыми тремя цифрами, иначе первые четыре цифры; у прочих — `None` + - `RelationOutcome(column: str, rule: RelationRule, codes: tuple[str, ...], computed: bool, expected: Decimal | None, + actual: Decimal | None, within_tolerance: bool, within_rounding: bool)` со свойством `hard_failed: bool` + (`rule.strict and computed and not within_tolerance and not within_rounding`); `expected is None` — цель пуста, + `actual is None` — пусты все члены (P1-21) + - `evaluate_relations(lines: Sequence[EvalLine], columns: Sequence[str], document_type: DocumentType, + variant: FormVariant | None, rules: Rules) -> list[RelationOutcome]` — строки — в порядке документа (вызывающий + сортирует; от порядка зависят родители подстрок); набор `relations_for(document_type, + variant or FULL)` + сгенерированные «родитель = Σ подстрок» (мягкие, только родители в отрезке формы, только в + колонке, где хоть одна подстрока непуста); значение кода — сумма `amount` по строкам `is_check_line`; посчитано — + непуста цель или член; правая часть как записано и с `abs_terms` по модулю, берётся сошедшийся вариант, иначе + ближайший (при равенстве — «как записано»); `within_tolerance`: `|expected − actual| ≤ tolerance`; + `within_rounding`: `|expected − actual| ≤ max(|expected|, |значения членов|) × rounding_tolerance_percent / 100`; + посчитанное соотношение с `expected is None` или `actual is None` — `within_tolerance = within_rounding = False` + (спека: «пустая цель при непустых членах и непустая цель при пустых членах — нарушение»; пусто ≠ 0) + - `checks_from(outcomes: Sequence[RelationOutcome], column_index_of: Mapping[str, int | None]) -> StatementChecks` — + `hard_failed` → `relations_failed`; посчитанное и не сошедшееся в пределах допуска прочее → `soft_relations_failed`; + `codes` — цель и члены; `relation` — текст правила + +- [ ] **Step 1: Write the failing test** + +```python +from decimal import Decimal as D + +from arm_statements.checks import checks_from, evaluate_relations, form_variant, subline_parents +from arm_statements.evaluation import EvalLine, EvalValue +from arm_statements.loader import Rules +from arm_statements.model import DocumentType, FormVariant, LineStatus + +BAL, INC = DocumentType.BALANCE, DocumentType.INCOME + + +def line(code: str | None, *amounts: str | None, status: LineStatus = LineStatus.OK, order: int = 0) -> EvalLine: + values = tuple(EvalValue(str(i), None if a is None else D(a), a, 100.0, True) for i, a in enumerate(amounts)) + return EvalLine(f"k{order}{code}", order, status, code, 100.0, False, values) + + +def failed(lines: list[EvalLine], doc: DocumentType, variant: FormVariant | None, rules: Rules): + outcomes = evaluate_relations(lines, ["0"], doc, variant, rules) + return checks_from(outcomes, {"0": 0}) + + +def test_form_variant() -> None: + assert form_variant(BAL, ["1150", "1210", "1600", "1310", "1700"]) is FormVariant.SIMPLIFIED + assert form_variant(BAL, ["1100", "1600"]) is FormVariant.FULL + assert form_variant(INC, ["2110", "2400"]) is FormVariant.SIMPLIFIED + assert form_variant(INC, ["2110", "2100", "2400"]) is FormVariant.FULL + assert form_variant(DocumentType.DISCLOSURE, ["5101"]) is None + + +def test_empty_target_is_violation(rules: Rules) -> None: + lines = [line("1110", "30"), line("1100", "30"), line("1210", "70"), line("1200", "70"), + line(None, "100", status=LineStatus.DETAIL)] + checks = failed(lines, BAL, FormVariant.FULL, rules) + assert [f.relation for f in checks.relations_failed] == ["1600 = 1100 + 1200"] + assert (checks.relations_failed[0].expected, checks.relations_failed[0].actual) == (None, D("100")) + + +def test_target_without_members_is_violation(rules: Rules) -> None: + lines = [line("1110", "30"), line("1100", "30"), line("1210", "70"), line("1200", "70"), line("1600", "100")] + checks = failed(lines, BAL, FormVariant.FULL, rules) + assert [f.relation for f in checks.relations_failed] == ["1600 = 1700"] + assert checks.relations_failed[0].codes == ("1600", "1700") + assert (checks.relations_failed[0].expected, checks.relations_failed[0].actual) == (D("100"), None) + + +def test_zero_target_with_empty_members_is_violation(rules: Rules) -> None: + lines = [line("1600", "0"), line("1110", "0"), line("1100", "0")] + hard = failed(lines, BAL, FormVariant.FULL, rules).relations_failed + assert [(f.relation, f.expected, f.actual) for f in hard] == [("1600 = 1700", D("0"), None)] + + +def test_duplicate_code_is_summed(rules: Rules) -> None: + lines = [line("2300", "849"), line("2410", "100"), line("2410", "649"), line("2400", "100")] + outcomes = evaluate_relations(lines, ["0"], INC, FormVariant.FULL, rules) + target = next(o for o in outcomes if o.rule.relation == "2400 = 2300 - 2410 + 2420") + assert target.within_tolerance + + +def test_simplified_income_soft(rules: Rules) -> None: + lines = [line("2110", "1000"), line("2120", "-600"), line("2410", "50"), line("2400", "500")] + checks = failed(lines, INC, FormVariant.SIMPLIFIED, rules) + assert checks.relations_failed == () + assert [f.relation for f in checks.soft_relations_failed] == ["2400 = 2110 - 2120 - 2330 + 2340 - 2350 - 2410"] + + +def test_rounding_goes_soft(rules: Rules) -> None: + lines = [line("1600", "17000000009.02"), line("1700", "17000000000"), line("1100", "17000000009.02"), + line("1300", "17000000000")] + checks = failed(lines, BAL, FormVariant.FULL, rules) + assert "1600 = 1700" in [f.relation for f in checks.soft_relations_failed] + assert "1600 = 1700" not in [f.relation for f in checks.relations_failed] + + +def test_abs_terms_hold_with_minus(rules: Rules) -> None: + lines = [line("2110", "100"), line("2120", "-60"), line("2100", "40")] + hard = failed(lines, INC, FormVariant.FULL, rules).relations_failed + assert "2100 = 2110 - 2120" not in [f.relation for f in hard] + + +def test_sublines(rules: Rules) -> None: + assert subline_parents(["1230", "12301", "12302", "1240", "11101"]) == [None, "1230", "1230", None, "1110"] + lines = [line("1230", "10"), line("12301", "4"), line("12302", "5")] + soft = failed(lines, BAL, FormVariant.FULL, rules).soft_relations_failed + assert [f.relation for f in soft] == ["1230 = 12301 + 12302"] + empty = [line("1230", "10"), line("12301", None), line("12302", None)] + assert failed(empty, BAL, FormVariant.FULL, rules).soft_relations_failed == () +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_checks.py -v` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement `evaluation.py` и `checks.py`** по Interfaces (доноры: tabular-parser `checks.py`, arm-core + `statement_checks.py::subline_parents`). Текст сгенерированного правила — `"<родитель> = <подстрока> + …"` в порядке + строк. + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: шаг 7 — посчитанные соотношения §7, abs_terms, допуск и округление, упрощённый набор, подстроки" +``` + +--- + +### Task 14: Шаг 8 — подтверждение неуверенного кода структурой и редакция + +**Files:** +- Create: `$R/arm_statements/structure.py` +- Test: `$R/arm_statements/tests/test_structure.py` + +**Interfaces:** +- Consumes: `EvalLine`, `is_check_line`, `CodeOrders`, `Edition`, `FormVariant`, `subline_parents`. +- Produces (`arm_statements/structure.py`): + - `FSBU4_FROM = date(2025, 1, 1)` — эффективная дата после 2024-12-31 → `fsbu4` + - `edition_by_date(effective: date | None) -> Edition | None` + - `StructureResult(edition: Edition | None, unconfirmed: frozenset[str])` — `unconfirmed` — ключи строк с + неуверенным кодом, не подтверждённым структурой (их `needs_review = true`) + - `confirm_codes(lines: Sequence[EvalLine], document_type: DocumentType, variant: FormVariant | None, + effective: date | None, rules: Rules) -> StructureResult` + - `choose_edition(document_type: DocumentType, codes: Sequence[tuple[str, bool]], effective: date | None, + rules: Rules, *, variant: FormVariant | None = None) -> Edition | None` — **публичная**: шаги 2–3 и 6 алгоритма + ниже без подтверждения кодов. `codes` — коды строк `ok` по порядку строк как пары `(код, уверенный)`; уверенный ⇔ + `confidence == 100` или код зафиксирован аналитиком; пятизначные коды и коды вне перечня в последовательность не + входят. `variant is None` → `form_variant(document_type, коды)` (у `BALANCE`/`INCOME` — всегда не `None`). + `DISCLOSURE` → `edition_by_date(effective)`. `confirm_codes` и `recompute` берут редакцию **этой** функцией (с + `variant` документа), поэтому ядро, зовущее её при приёме результата, получает ту же редакцию, что пересчёт после + правки (P1-25) + +Алгоритм (спека «Шаг 8 — подтверждение неуверенного кода структурой»; сигнатура его не определяет): + +1. Последовательность `S` — строки `is_check_line` с `order is not None` и четырёхзначным кодом, по `order`. + Неуверенный код — `code_confidence is None or code_confidence < 100`, и `code_fixed` ложно. +2. Для редакции `e` и перечня `L = sequence(e, document_type, variant or FULL)`: в `S` остаются только коды из `L` + (позиции сохраняются). Лучшая подпоследовательность — взвешенная LCS `S` × `L` по равенству кода с весом позиции + `w = (len(S) + 1) * уверенность + 1` (уверенный — 1, иначе 0): максимум суммы весов = лексикографически «больше + уверенных, затем длиннее»; код с повтором в `L` может совпасть столько раз, сколько стоит в `L`. +3. Редакция — с большим оптимумом; равенство — `edition_by_date(effective)`; равенство и `effective is None` → + `edition = None`, неуверенный код подтверждён, только если подтверждён в обеих редакциях (P1-4). +4. Неуверенная позиция `i` подтверждена ⇔ оптимум с запретом позиции `i` строго меньше общего оптимума (то есть `i` + входит во **все** лучшие подпоследовательности). +5. Пятизначный неуверенный код подтверждён, если строка-родитель (`subline_parents`) стоит раньше по `order` и + уверенна или подтверждена; родителя нет — не подтверждён. +6. `document_type` `DISCLOSURE` или нет неуверенных кодов → `unconfirmed = ∅`; `edition` — по оптимуму, при пустой `S` + — `edition_by_date(effective)`. + +- [ ] **Step 1: Write the failing test** + +```python +from datetime import date + +from arm_statements.evaluation import EvalLine +from arm_statements.loader import Rules +from arm_statements.model import DocumentType, Edition, FormVariant, LineStatus +from arm_statements.structure import choose_edition, confirm_codes + +BAL = DocumentType.BALANCE +Q3_2025 = date(2025, 9, 30) + + +def seq(*items: tuple[str, float]) -> list[EvalLine]: + return [EvalLine(f"r{i}", i, LineStatus.OK, code, conf, False, ()) for i, (code, conf) in enumerate(items)] + + +def unconfirmed(lines: list[EvalLine], rules: Rules, effective: date | None = Q3_2025) -> set[str]: + return set(confirm_codes(lines, BAL, FormVariant.FULL, effective, rules).unconfirmed) + + +def test_uncertain_code_in_place(rules: Rules) -> None: + assert unconfirmed(seq(("1220", 100.0), ("1230", 50.0), ("1240", 100.0)), rules) == set() + + +def test_swapped_uncertain_neighbours(rules: Rules) -> None: + lines = seq(("1210", 100.0), ("1230", 50.0), ("1220", 50.0), ("1240", 100.0)) + assert unconfirmed(lines, rules) == {"r1", "r2"} + + +def test_conflicting_anchors(rules: Rules) -> None: + lines = seq(("1210", 100.0), ("1240", 100.0), ("1230", 100.0), ("1250", 50.0)) + assert unconfirmed(lines, rules) == set() + + +def test_legal_repeat_1220_fsbu4(rules: Rules) -> None: + lines = seq(("1215", 100.0), ("1220", 100.0), ("1220", 50.0), ("1230", 100.0)) + assert unconfirmed(lines, rules) == set() + + +def test_repeat_outside_orders(rules: Rules) -> None: + lines = seq(("1220", 100.0), ("1230", 100.0), ("1230", 50.0), ("1240", 100.0)) + assert unconfirmed(lines, rules) == {"r2"} + + +def test_66n_blank_in_2025(rules: Rules) -> None: + lines = seq(("1110", 100.0), ("1120", 60.0), ("1130", 100.0)) + result = confirm_codes(lines, BAL, FormVariant.FULL, Q3_2025, rules) + assert (result.edition, result.unconfirmed) == (Edition.RAS_66N, frozenset()) + + +def test_tie_resolved_by_date(rules: Rules) -> None: + lines = seq(("1210", 100.0), ("1230", 100.0)) + assert confirm_codes(lines, BAL, FormVariant.FULL, Q3_2025, rules).edition is Edition.FSBU4 + assert confirm_codes(lines, BAL, FormVariant.FULL, date(2024, 12, 31), rules).edition is Edition.RAS_66N + + +def test_choose_edition_public(rules: Rules) -> None: + blank_66n = [("1110", True), ("1120", False), ("1130", True)] + assert choose_edition(BAL, blank_66n, Q3_2025, rules) is Edition.RAS_66N + tie = [("1210", True), ("1230", True)] + assert choose_edition(BAL, tie, Q3_2025, rules) is Edition.FSBU4 + assert choose_edition(BAL, tie, date(2024, 12, 31), rules) is Edition.RAS_66N + assert choose_edition(BAL, tie, None, rules) is None + assert choose_edition(BAL, [], Q3_2025, rules) is Edition.FSBU4 + assert choose_edition(DocumentType.DISCLOSURE, [("5101", True)], date(2024, 12, 31), rules) is Edition.RAS_66N + + +def test_choose_edition_equals_confirm_codes(rules: Rules) -> None: + lines = seq(("1110", 100.0), ("1120", 60.0), ("1130", 100.0)) + pairs = [(ln.line_code or "", (ln.code_confidence or 0.0) >= 100.0) for ln in lines] + expected = confirm_codes(lines, BAL, FormVariant.FULL, Q3_2025, rules).edition + assert choose_edition(BAL, pairs, Q3_2025, rules, variant=FormVariant.FULL) is expected + + +def test_confident_nonstandard_code_not_flagged(rules: Rules) -> None: + assert unconfirmed(seq(("1230", 100.0), ("1231", 100.0), ("11101", 100.0)), rules) == set() + + +def test_subline_follows_confirmed_parent(rules: Rules) -> None: + lines = seq(("1220", 100.0), ("1230", 50.0), ("12301", 60.0), ("1240", 100.0)) + assert unconfirmed(lines, rules) == set() + orphan = seq(("1220", 100.0), ("12301", 60.0), ("1240", 100.0)) + assert unconfirmed(orphan, rules) == {"r1"} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_structure.py -v` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement `structure.py`** по алгоритму выше (DP `O(|S|·|L|)`, повтор с запретом позиции — на каждую + неуверенную позицию; размеры форм ≤ 60 строк). + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: шаг 8 — подтверждение неуверенного кода перечнем бланка, якоря, законные повторы, выбор редакции" +``` + +--- + +### Task 15: Шаг 8 — `needs_review` значений и строк, поля шапки, `evaluate` + +**Files:** +- Create: `$R/arm_statements/review.py` +- Test: `$R/arm_statements/tests/test_review.py` + +**Interfaces:** +- Consumes: `EvalLine`, `evaluate_relations`, `RelationOutcome`, `confirm_codes`, `sign_form_of`, `make_warning`, + `StatementHeader`, `DocumentDates`, `CheckRules.abs_codes`. +- Produces (`arm_statements/review.py`): + - `value_needs_review(lines: Sequence[EvalLine], outcomes: Sequence[RelationOutcome], abs_codes: frozenset[str], + rules: Rules, *, accepted: bool = False) -> dict[tuple[str, str], bool]` — ключ `(line.key, value.column)`; правила спеки «Шаг 8 — + `needs_review` значения»: `DETAIL` → `False`; `fixed` → `False`; нуждается в покрытии ⇔ `confidence is not None and + confidence < 100` или `not is_recognized`; покрыто ⇔ `amount is not None` и в колонке есть посчитанное соотношение с + кодом строки, у которого `within_tolerance` (жёсткое — ещё и `within_rounding`); знак члена `abs_codes`, нуждающегося + в покрытии, подтверждён ⇔ его `sign_form_of(text)` равна форме строгого большинства прочих значений `abs_codes` той + же колонки с `amount is not None`, `is_recognized`, не `fixed`; `True` — нуждается и (не покрыто или знак не + подтверждён), а также у всех значений строк `is_check_line` с кодами `hard_failed`-соотношения в его колонке — кроме `accepted=True` (жёсткие нарушения акцептованы и статус + не держат, P1-20) + - `line_needs_review(lines: Sequence[EvalLine], structure: StructureResult) -> dict[str, bool]` — `True` только у + ключей `structure.unconfirmed`; `code_fixed` и не-`OK` → `False` + - `Evaluation(variant: FormVariant | None, edition: Edition | None, outcomes: tuple[RelationOutcome, ...], + line_needs_review: Mapping[str, bool], value_needs_review: Mapping[tuple[str, str], bool], hard_failures: int)` + - `evaluate(lines: Sequence[EvalLine], columns: Sequence[str], document_type: DocumentType, + effective: date | None, rules: Rules, *, accepted: bool = False) -> Evaluation` — `variant = form_variant(тип, коды is_check_line)`, затем + `evaluate_relations`, `confirm_codes`, `value_needs_review`, `line_needs_review`; `hard_failures` — число + `hard_failed` + - `review_header(header: StatementHeader, dates: DocumentDates, rules: Rules) -> + tuple[StatementHeader, list[StatementWarning]]` — спека «Шаг 8 — поля шапки» и таблица уровней: дата отчёта с + уверенностью < 100, не равная `dates.confident_latest_absolute` → `needs_review_fields = ("report_date",)` и + `REPORT_DATE_UNCERTAIN` (`field="report_date"`); `NO_ORGANIZATION_NO_INN` — нет ни организации, ни ИНН; + `INN_NOT_FOUND` — нет ИНН при организации; `ORGANIZATION_NOT_FOUND` — нет организации при ИНН; `INN_UNCERTAIN` / + `ORGANIZATION_UNCERTAIN` — поле есть с уверенностью < 100 (`field`); `REPORT_DATE_UNPARSED` — текст даты есть, + дата не разобрана, а абсолютные даты колонок есть; `PERIOD_UNKNOWN` — нет ни даты отчёта, ни `latest_absolute`; + `UNITS_NOT_FOUND` — нет единиц + +- [ ] **Step 1: Write the failing test** + +```python +from datetime import date +from decimal import Decimal as D + +from arm_statements.columns import DocumentDates +from arm_statements.evaluation import EvalLine, EvalValue +from arm_statements.header import parse_header +from arm_statements.loader import Rules +from arm_statements.model import DocumentType, LineStatus +from arm_statements.review import evaluate, review_header +from arm_statements.tests.builders import hdr + +OK, DETAIL, UNREADABLE = LineStatus.OK, LineStatus.DETAIL, LineStatus.UNREADABLE +NO_DATES = DocumentDates(None, None, None, None) + + +def ln(code: str | None, text: str, amount: str | None, conf: float = 100.0, recognized: bool = True, + status: LineStatus = OK, order: int = 0) -> EvalLine: + value = EvalValue("0", None if amount is None else D(amount), text, conf, recognized) + return EvalLine(f"{code}-{order}", order, status, code, 100.0, False, (value,)) + + +def flags(lines: list[EvalLine], doc: DocumentType, rules: Rules) -> dict[str, bool]: + result = evaluate(lines, ["0"], doc, date(2025, 9, 30), rules) + return {key: flag for (key, _), flag in result.value_needs_review.items()} + + +def test_guess_confirmed_by_relation(rules: Rules) -> None: + lines = [ln("1210", "100", "100", order=0), ln("1230", "42831 1", "428311", recognized=False, order=1), + ln("1200", "428411", "428411", order=2)] + assert flags(lines, DocumentType.BALANCE, rules)["1230-1"] is False + + +def test_sign_in_curly_bracket_confirmed(rules: Rules) -> None: + lines = [ln("2110", "10000000", "10000000", order=0), + ln("2120", "(7744536}", "-7744536", recognized=False, order=1), + ln("2100", "2255464", "2255464", order=2), ln("2210", "(100)", "-100", order=3), + ln("2220", "(200)", "-200", order=4), ln("2200", "2255164", "2255164", order=5)] + assert flags(lines, DocumentType.INCOME, rules)["2120-1"] is False + + +def test_sign_outside_convention(rules: Rules) -> None: + lines = [ln("2110", "100000", "100000", order=0), ln("2120", "38378", "38378", conf=60.0, order=1), + ln("2100", "61622", "61622", order=2), ln("2210", "(100)", "-100", order=3), + ln("2220", "(200)", "-200", order=4)] + assert flags(lines, DocumentType.INCOME, rules)["2120-1"] is True + + +def test_null_amount_and_unchecked_code(rules: Rules) -> None: + lines = [ln("2120", "{38378)", None, recognized=False, order=0), ln("2900", "15", "15", conf=80.0, order=1)] + result = flags(lines, DocumentType.INCOME, rules) + assert (result["2120-0"], result["2900-1"]) == (True, True) + + +def test_detail_values_never_flagged(rules: Rules) -> None: + lines = [ln("2110", "100", "100", order=0), ln(None, "1 425 130", "1425130", conf=78.0, status=DETAIL, order=1)] + assert flags(lines, DocumentType.INCOME, rules)["None-1"] is False + + +def test_soft_relation_covers_only_exactly(rules: Rules) -> None: + exact = [ln("2300", "1000", "1000", order=0), ln("2410", "200", "200", order=1), + ln("2400", "800", "800", conf=66.0, order=2)] + assert flags(exact, DocumentType.INCOME, rules)["2400-2"] is False + rounding = [ln("2300", "17000000000", "17000000000", order=0), ln("2410", "0", "0", order=1), + ln("2400", "17000000009", "17000000009", conf=66.0, order=2)] + assert flags(rounding, DocumentType.INCOME, rules)["2400-2"] is True + + +def test_hard_failure_flags_clean_cells(rules: Rules) -> None: + lines = [ln("1210", "100", "100", order=0), ln("1230", "50", "50", order=1), ln("1200", "999", "999", order=2)] + result = evaluate(lines, ["0"], DocumentType.BALANCE, None, rules) + assert result.hard_failures >= 1 + assert all(result.value_needs_review[(f"{c}-{i}", "0")] for i, c in enumerate(["1210", "1230", "1200"])) + + +def test_unreadable_values(rules: Rules) -> None: + lines = [ln(None, "7", "7", conf=60.0, status=UNREADABLE), ln(None, "5", "5", status=UNREADABLE, order=1)] + result = flags(lines, DocumentType.BALANCE, rules) + assert (result["None-0"], result["None-1"]) == (True, False) + + +def test_report_date_confirmed_or_not(rules: Rules) -> None: + head = parse_header(hdr(report_date=("30.09.2025", 60.0), organization_name="ООО «Пример»", + inn="7700000000", units="тыс. руб."), rules) + ok_dates = DocumentDates(date(2025, 9, 30), date(2025, 9, 30), date(2025, 9, 30), date(2025, 9, 30)) + reviewed, warnings = review_header(head, ok_dates, rules) + assert (reviewed.needs_review_fields, warnings) == ((), []) + relative_only = DocumentDates(date(2025, 9, 30), None, None, None) + reviewed, warnings = review_header(head, relative_only, rules) + assert reviewed.needs_review_fields == ("report_date",) + assert [(w.code, w.severity.value, w.field) for w in warnings] == [("REPORT_DATE_UNCERTAIN", "error", "report_date")] + + +def test_header_presence_warnings(rules: Rules) -> None: + _, no_inn = review_header(parse_header(hdr(organization_name="ООО «Пример»", report_date="30.09.2025", + units="тыс. руб."), rules), NO_DATES, rules) + assert [w.code for w in no_inn] == ["INN_NOT_FOUND"] + _, nothing = review_header(parse_header(hdr(), rules), NO_DATES, rules) + assert {w.code for w in nothing} == {"NO_ORGANIZATION_NO_INN", "PERIOD_UNKNOWN", "UNITS_NOT_FOUND"} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_review.py -v` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement `review.py`** по Interfaces. `abs_codes` — `rules.checks.abs_codes(document_type, variant or + FULL)`. + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: шаг 8 — needs_review по покрытию §7 и конвенции знака, проверки полей шапки" +``` + +--- + +### Task 16: Функция статуса документа и конверта + +**Files:** +- Create: `$R/arm_statements/status.py` +- Test: `$R/arm_statements/tests/test_status.py` + +**Interfaces:** +- Consumes: `DocumentType`, `LineStatus`, `Severity`, `DocStatus`, `EnvelopeStatus`, `StatementWarning`, + `StatementDocument`, `PeriodKind`. +- Produces (`arm_statements/status.py`): + - `StatusLine(line_status: LineStatus, needs_review: bool)`; `StatusValue(line_status: LineStatus, needs_review: bool)` + - `StatusInput(document_type: DocumentType, layout_found: bool, has_organization_or_inn: bool, period_known: bool, + needs_review_fields: tuple[str, ...], lines: tuple[StatusLine, ...], values: tuple[StatusValue, ...], + hard_failures: int, accepted: bool, warning_severities: tuple[Severity, ...])` + - `document_status(data: StatusInput) -> DocStatus` — спека «Шаг 8 — статус документа и конверта»: `DISCLOSURE` → + `DONE`; иначе `DONE` ⇔ `layout_found` ∧ `has_organization_or_inn` ∧ `period_known` ∧ все строки `OK`/`DETAIL` ∧ нет + строк с `needs_review` ∧ нет значений с `needs_review`, **кроме значений строк `DETAIL`** ∧ `needs_review_fields` + пуст ∧ (`hard_failures == 0` или `accepted`) ∧ нет `Severity.ERROR` в `warning_severities` + - `envelope_status(document_statuses: Sequence[DocStatus], file_warnings: Sequence[StatementWarning]) -> + EnvelopeStatus` — документов нет → `ERROR`; все `DONE` и нет предупреждений файла уровня `error` → `DONE`; иначе + `NEEDS_CHECK` + - `status_input(document: StatementDocument) -> StatusInput` — **публичная**: вход функции статуса из документа + шагов 2–8 (статус документа — функция самого документа): `layout_found = document.lines != ()` (строк нет ⇔ столбец + кодов не найден ни у одного сегмента, задача 17 п. 2); `has_organization_or_inn` — `header.organization_name.value` + или `header.inn.value` не `None`; `period_known` — `header.report_date.value` не `None` или есть колонка с + `period_kind is ABSOLUTE` и `period_date` не `None`; `needs_review_fields = header.needs_review_fields`; `lines` и + `values` — `(line_status, needs_review)` строк и их значений; `hard_failures = len(checks.relations_failed)` (`checks + is None` → 0); `accepted = False`; `warning_severities` — уровни `document.warnings`. Конвейер (задача 17) считает + `status` как `document_status(status_input(<документ>))`; ядро — тем же правилом по документу контракта + (`status_input_from_contract`, задача 19) + +- [ ] **Step 1: Write the failing test** + +```python +from dataclasses import replace +from datetime import date +from decimal import Decimal as D + +from arm_statements.header import parse_header +from arm_statements.issues import make_warning +from arm_statements.loader import Rules +from arm_statements.model import ( + DocStatus, DocumentType, EnvelopeStatus, FormVariant, LineStatus, PeriodKind, Severity, StatementChecks, + StatementColumn, StatementDocument, StatementLine, StatementValue, +) +from arm_statements.tests.builders import hdr +from arm_statements.status import ( + StatusInput, StatusLine, StatusValue, document_status, envelope_status, status_input, +) + +OK, DETAIL, UNREADABLE = LineStatus.OK, LineStatus.DETAIL, LineStatus.UNREADABLE +DONE, CHECK = DocStatus.DONE, DocStatus.NEEDS_CHECK +BASE = StatusInput( + document_type=DocumentType.BALANCE, layout_found=True, has_organization_or_inn=True, period_known=True, + needs_review_fields=(), lines=(StatusLine(OK, False),), values=(StatusValue(OK, False),), hard_failures=0, + accepted=False, warning_severities=(Severity.INFO,), +) + + +def test_clean_form_done() -> None: + assert document_status(BASE) is DONE + + +def test_unreadable_line_blocks() -> None: + assert document_status(replace(BASE, lines=(StatusLine(OK, False), StatusLine(UNREADABLE, False)))) is CHECK + + +def test_detail_value_does_not_block() -> None: + lines = (StatusLine(OK, False), StatusLine(DETAIL, False)) + values = (StatusValue(OK, False), StatusValue(DETAIL, True)) + assert document_status(replace(BASE, lines=lines, values=values)) is DONE + + +def test_blocking_conditions() -> None: + assert document_status(replace(BASE, values=(StatusValue(OK, True),))) is CHECK + assert document_status(replace(BASE, lines=(StatusLine(OK, True),))) is CHECK + assert document_status(replace(BASE, needs_review_fields=("report_date",))) is CHECK + assert document_status(replace(BASE, hard_failures=2)) is CHECK + assert document_status(replace(BASE, hard_failures=2, accepted=True)) is DONE + assert document_status(replace(BASE, warning_severities=(Severity.ERROR,))) is CHECK + assert document_status(replace(BASE, layout_found=False)) is CHECK + assert document_status(replace(BASE, has_organization_or_inn=False)) is CHECK + assert document_status(replace(BASE, period_known=False)) is CHECK + + +def test_disclosure_always_done() -> None: + disclosure = replace(BASE, document_type=DocumentType.DISCLOSURE, layout_found=False, lines=(), values=()) + assert document_status(disclosure) is DONE + + +def test_envelope(rules: Rules) -> None: + assert envelope_status([DONE, CHECK], []) is EnvelopeStatus.NEEDS_CHECK + assert envelope_status([DONE], []) is EnvelopeStatus.DONE + lost = make_warning("TABLE_LOST", "таблица без колонки кодов", rules, locator="file:a.json;table:1") + assert envelope_status([DONE], [lost]) is EnvelopeStatus.NEEDS_CHECK + assert envelope_status([], []) is EnvelopeStatus.ERROR + + +def test_status_input_from_document(rules: Rules) -> None: + head = parse_header(hdr(organization_name="ООО «Пример»", units="тыс. руб."), rules) + column = StatementColumn(2, "На 30.09.2025", 100.0, date(2025, 9, 30), PeriodKind.ABSOLUTE) + value = StatementValue(2, D("5"), None, "5", 60.0, True, needs_review=True) + detail = StatementLine(1, "row:1", DETAIL, None, None, None, "в т. ч.", 100.0, "row:0", False, (value,)) + line = StatementLine(0, "row:0", OK, "1210", "1210", 100.0, "Запасы", 100.0, None, False, + (replace(value, needs_review=False),)) + doc = StatementDocument(DocumentType.BALANCE, FormVariant.FULL, "file:t.json;table:0", head, (column,), + (line, detail), StatementChecks((), ()), DocStatus.DONE, ()) + data = status_input(doc) + assert (data.layout_found, data.has_organization_or_inn, data.period_known, data.hard_failures) == ( + True, True, True, 0) + assert data.values == (StatusValue(OK, False), StatusValue(DETAIL, True)) + assert document_status(data) is DONE + empty = replace(doc, columns=(), lines=(), checks=None) + assert (status_input(empty).layout_found, status_input(empty).period_known) == (False, False) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_status.py -v` +Expected: FAIL — `ModuleNotFoundError` + +- [ ] **Step 3: Implement `status.py`** по Interfaces. + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: общая функция статуса документа (detail не держит) и статуса конверта" +``` + +--- + +### Task 17: Шаг 6 и конвейер кандидата + +**Files:** +- Create: `$R/arm_statements/pipeline.py` +- Modify: `$R/arm_statements/__init__.py` (экспорт) +- Test: `$R/arm_statements/tests/test_pipeline.py` + +**Interfaces:** +- Consumes: всё из задач 3–16. +- Produces (`arm_statements/pipeline.py`): + - `normalize_candidate(candidate: FormCandidate, rules: Rules | None = None) -> CandidateResult` + - `normalize(candidates: Sequence[FormCandidate], rules: Rules | None = None) -> NormalizationResult` — по порядку; + исключения не перехватывает (хост ловит их на кандидате и пишет `TABLE_PARSE_ERROR`) + +Порядок `normalize_candidate` (спека «Шаг 6 — тип формы, вариант и документ», «Шаг 8»): +1. `header = replace(parse_header(candidate.header), form_name=)`, где `<title>` — `candidate.evidence.title.text` + как есть, если `title` есть и текст непуст после `strip()`, иначе `None` (заголовок формы не теряется при `normalize` + — документов меньше, чем кандидатов, и хосту не нужно сопоставлять документ с кандидатом); `ref` ячеек шапки и + `evidence` в выход не переносятся (у `HeaderField` нет `ref`); `layout = find_layout(candidate, header)`. +2. Нет столбца кодов (`not layout.code_found`): `type_hint` есть → документ без строк (`columns=()`, `lines=()`, + `checks=None`, `form_variant=None` у `DISCLOSURE`, иначе `FULL`… — см. P1-19), `DISCLOSURE` → `DONE` без + предупреждений шапки, иначе `CODE_COLUMN_NOT_FOUND` и статус по функции статуса (`layout_found=False`); `type_hint` + нет → документа нет: у `OCR` при содержимом (ячейка с цифрой или `is_disguised_digit`) — предупреждение файла + `TABLE_LOST`, без содержимого — `TABLE_SKIPPED`; у `EXCEL` — `SHEET_OTHER` с `locator = "sheet:<имя первого листа>"` + (из `candidate.locator`); у файловых предупреждений `locator = candidate.locator` (OCR). +3. Строки шага 3; тип — `document_type_of(majority_section(коды строк OK))`; кодов `OK` нет — `type_hint`; нет и его — + как п. 2 без `type_hint`. Тип по кодам противоречит не-`None` `type_hint` → `TYPE_CONFLICT` (`error`). +4. Тип `DISCLOSURE` → документ без строк, `DONE`. +5. Иначе: `build_lines`, `units_warnings`; `EvalLine` из `StatementLine` (`key = ref`, `order = row_index`, колонки + `str(column_index)`, `fixed=False`); `evaluate(..., effective=layout.dates.effective)`; `checks_from`; + `review_header`; значения и строки получают `needs_review` (`evaluate(..., accepted=False)`); документ: `form_variant` из `Evaluation`, + `checks` (у документа со строками — всегда, даже пустые списки), предупреждения в порядке: адаптера, раскладки, + строк (`ROW_SPLIT`, `VALUE_OUT_OF_RANGE`), единиц, `TYPE_CONFLICT`, шапки; `status = + document_status(status_input(<документ>))` (задача 16) — у документов без строк (п. 2, п. 4) так же. + +- [ ] **Step 1: Write the failing test** + +```python +from decimal import Decimal as D + +from arm_statements import document_status, normalize, normalize_candidate, status_input +from arm_statements.loader import Rules +from arm_statements.model import DocStatus, DocumentType, FormVariant, Severity +from arm_statements.tests.builders import excel, hdr, ocr + +HEAD = ["Наименование показателя", "Код", "На 30.09.2025"] +FULL_HEADER = hdr(organization_name="ООО «Пример»", inn="7700000000", report_date="30.09.2025", units="тыс. руб.") + + +def bal_rows() -> list[list[str]]: + return [HEAD, ["Запасы", "1210", "60"], ["Дебиторы", "1230", "40"], ["Итого II", "1200", "100"], + ["НМА", "1110", "50"], ["Итого I", "1100", "50"], ["БАЛАНС", "1600", "150"], + ["Капитал", "1310", "150"], ["Итого III", "1300", "150"], ["БАЛАНС", "1700", "150"]] + + +def test_clean_ocr_balance_done(rules: Rules) -> None: + cand = ocr(bal_rows(), header=FULL_HEADER, type_hint=DocumentType.BALANCE, title="Бухгалтерский баланс") + result = normalize_candidate(cand, rules) + doc = result.document + assert doc is not None and result.file_warnings == () + assert doc.header.form_name == "Бухгалтерский баланс" + assert document_status(status_input(doc)) is doc.status + assert (doc.document_type, doc.form_variant, doc.status) == (DocumentType.BALANCE, FormVariant.FULL, DocStatus.DONE) + assert doc.checks is not None and doc.checks.relations_failed == () + assert doc.header.inn.value == "7700000000" + + +def test_disclosure_hint_with_balance_codes(rules: Rules) -> None: + doc = normalize_candidate(ocr(bal_rows(), header=FULL_HEADER, type_hint=DocumentType.DISCLOSURE), rules).document + assert doc is not None and doc.document_type is DocumentType.BALANCE + assert [(w.code, w.severity) for w in doc.warnings if w.code == "TYPE_CONFLICT"] == [("TYPE_CONFLICT", + Severity.ERROR)] + + +def test_balance_hint_with_income_codes(rules: Rules) -> None: + rows = [HEAD, ["Выручка", "2110", "100"], ["Себестоимость", "2120", "(60)"], ["Валовая", "2100", "40"], + ["Коммерческие", "2210", "(10)"], ["Прибыль от продаж", "2200", "30"]] + doc = normalize_candidate(ocr(rows, header=FULL_HEADER, type_hint=DocumentType.BALANCE), rules).document + assert doc is not None and doc.document_type is DocumentType.INCOME + assert "TYPE_CONFLICT" in [w.code for w in doc.warnings] + + +def test_excel_other_sheet(rules: Rules) -> None: + result = normalize_candidate(excel([("Примечания", [["Пояснительный текст без цифр"]])]), rules) + assert result.document is None + assert [(w.code, w.severity, w.locator) for w in result.file_warnings] == [("SHEET_OTHER", Severity.INFO, + "sheet:Примечания")] + + +def test_ocr_lost_and_skipped_tables(rules: Rules) -> None: + lost = normalize_candidate(ocr([["Итого", "", "15 000"]], locator="file:a.json;table:1"), rules) + assert [(w.code, w.locator) for w in lost.file_warnings] == [("TABLE_LOST", "file:a.json;table:1")] + skipped = normalize_candidate(ocr([["Руководитель", "Иванов"]]), rules) + assert [w.code for w in skipped.file_warnings] == ["TABLE_SKIPPED"] and skipped.document is None + + +def test_hint_without_code_column(rules: Rules) -> None: + doc = normalize_candidate(ocr([["Итого", "15 000"]], type_hint=DocumentType.BALANCE, header=FULL_HEADER), + rules).document + assert doc is not None and (doc.lines, doc.columns, doc.checks) == ((), (), None) + assert doc.status is DocStatus.NEEDS_CHECK and "CODE_COLUMN_NOT_FOUND" in [w.code for w in doc.warnings] + disc = normalize_candidate(ocr([["Текст"]], type_hint=DocumentType.DISCLOSURE), rules).document + assert disc is not None and (disc.status, disc.form_variant, disc.lines) == (DocStatus.DONE, None, ()) + assert disc.header.form_name is None + + +def test_disclosure_by_codes(rules: Rules) -> None: + rows = [HEAD, ["Пояснение", "5101", "1"], ["Пояснение", "5102", "2"], ["Пояснение", "5100", "3"]] + doc = normalize_candidate(ocr(rows, header=FULL_HEADER), rules).document + assert doc is not None and (doc.document_type, doc.lines, doc.status) == (DocumentType.DISCLOSURE, (), + DocStatus.DONE) + + +def test_simplified_balance(rules: Rules) -> None: + rows = [HEAD, ["Матактивы", "1150", "50"], ["Запасы", "1210", "50"], ["БАЛАНС", "1600", "100"], + ["Капитал", "1300", "100"], ["БАЛАНС", "1700", "100"]] + doc = normalize_candidate(ocr(rows, header=FULL_HEADER), rules).document + assert doc is not None and doc.form_variant is FormVariant.SIMPLIFIED + assert doc.checks is not None and doc.checks.relations_failed == () + + +def test_income_without_inn_still_done(rules: Rules) -> None: + rows = [["Наименование", "Код", "За 9 месяцев 2025 г."], ["Выручка", "2110", D("100")], + ["Себестоимость", "2120", D("-60")], ["Валовая прибыль", "2100", D("40")], + ["Прибыль от продаж", "2200", D("40")], ["Прибыль до налогообложения", "2300", D("40")], + ["Чистая прибыль", "2400", D("40")]] + head = hdr(organization_name="ООО «Пример»", units="тыс. руб.") + doc = normalize_candidate(excel([("ОФР", rows)], header=head), rules).document + assert doc is not None and doc.status is DocStatus.DONE + assert [(w.code, w.severity) for w in doc.warnings] == [("INN_NOT_FOUND", Severity.INFO)] + + +def test_normalize_collects(rules: Rules) -> None: + result = normalize([ocr(bal_rows(), header=FULL_HEADER), ocr([["Руководитель", "Иванов"]])], rules) + assert len(result.documents) == 1 and [w.code for w in result.file_warnings] == ["TABLE_SKIPPED"] +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_pipeline.py -v` +Expected: FAIL — `ImportError: cannot import name 'normalize'` + +- [ ] **Step 3: Implement `pipeline.py`** по порядку выше; `__init__.py` экспортирует имена раздела «Публичный API», + уже существующие к этой задаче. + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: шаг 6 и конвейер кандидата — тип по кодам, вариант формы, пояснения, предупреждения файла" +``` + +--- + +### Task 18: Точка входа пересчёта по сохранённым данным + +**Files:** +- Create: `$R/arm_statements/recompute.py` +- Modify: `$R/arm_statements/__init__.py` +- Test: `$R/arm_statements/tests/test_recompute.py` + +**Interfaces:** +- Consumes: `EvalLine`, `EvalValue`, `evaluate`, `checks_from`, `document_status`, `StatusInput`. +- Produces (`arm_statements/recompute.py`): + - `StoredColumn(key: str, column_index: int | None, period_date: date | None, period_kind: PeriodKind)` + - `StoredValue(column_key: str, amount: Decimal | None, text: str | None, confidence: float | None, + is_recognized: bool, value_fixed: bool)` + - `StoredLine(key: str, row_index: int | None, line_status: LineStatus, line_code: str | None, + line_code_confidence: float | None, line_name: str | None, code_fixed: bool, parent_key: str | None, + values: tuple[StoredValue, ...])` + - `StoredWarning(code: str, severity: Severity)` + - `StoredForm(document_type: DocumentType, effective_date: date | None, has_organization_or_inn: bool, + period_known: bool, needs_review_fields: tuple[str, ...], accepted: bool, warnings: tuple[StoredWarning, ...], + columns: tuple[StoredColumn, ...], lines: tuple[StoredLine, ...])` + - `RecomputeResult(form_variant: FormVariant | None, edition: Edition | None, checks: StatementChecks, + line_needs_review: Mapping[str, bool], value_needs_review: Mapping[tuple[str, str], bool], + detail_parents: Mapping[str, str | None], status: DocStatus)` — `value_needs_review` — ключ `(line.key, + column_key)`; `line_needs_review` и `value_needs_review` — по всем строкам и значениям формы + - `recompute(form: StoredForm, rules: Rules | None = None) -> RecomputeResult` + +Правила (спека «Точка входа пакета для пересчёта…»): `StoredLine` → `EvalLine(key, row_index, line_status, +line_code, line_code_confidence, code_fixed, values=EvalValue(column_key, amount, text, confidence, is_recognized, +fixed=value_fixed))`; `evaluate(lines, [c.key for c in columns], document_type, effective_date, +accepted=accepted)`; `checks = +checks_from(outcomes, {c.key: c.column_index})`; `detail_parents` — у каждой строки `DETAIL`: ближайшая выше по +`row_index` строка `OK` с `row_index is not None`, у строки `DETAIL` без `row_index` — `None`; статус — +`document_status` с `layout_found = any(row_index is not None)`, `hard_failures` из `Evaluation`, остальное из +`StoredForm`. `line_status`, суммы и коды не меняются. `edition` — из `Evaluation` (то есть `choose_edition` по строкам +`ok` с `row_index` с `variant` документа; уверенный код — `line_code_confidence == 100` или `code_fixed`). +`DISCLOSURE` — `checks` пустые, `form_variant = None`, `edition = edition_by_date(effective_date)`, `DONE`. + +- [ ] **Step 1: Write the failing test** + +```python +from dataclasses import replace +from datetime import date +from decimal import Decimal as D + +from arm_statements import StoredColumn, StoredForm, StoredLine, StoredValue, choose_edition, recompute +from arm_statements.loader import Rules +from arm_statements.model import DocStatus, DocumentType, Edition, FormVariant, LineStatus, PeriodKind + +OK, DETAIL = LineStatus.OK, LineStatus.DETAIL +COL = StoredColumn("c1", 2, date(2025, 9, 30), PeriodKind.ABSOLUTE) + + +def sl(key: str, row: int | None, code: str | None, amount: str | None, *, status: LineStatus = OK, + conf: float = 100.0, code_fixed: bool = False, value_conf: float = 100.0, value_fixed: bool = False, + recognized: bool = True) -> StoredLine: + value = StoredValue("c1", None if amount is None else D(amount), amount, value_conf, recognized, value_fixed) + return StoredLine(key, row, status, code, conf, None, code_fixed, None, (value,)) + + +def form(*lines: StoredLine, document_type: DocumentType = DocumentType.BALANCE) -> StoredForm: + return StoredForm(document_type, date(2025, 9, 30), True, True, (), False, (), (COL,), tuple(lines)) + + +def test_recompute_after_edit(rules: Rules) -> None: + result = recompute(form( + sl("l1210", 0, "1210", "100"), + sl("l1230", 1, "1230", "300", value_conf=60.0, value_fixed=True), + sl("l1250", 2, "1250", "50", conf=40.0, code_fixed=True), + sl("l1260", None, "1260", "50"), + sl("ldet", 3, None, "7", status=DETAIL, value_conf=60.0), + sl("l1200", 4, "1200", "500"), + ), rules) + # 1200 = 100 + 300 + 50 + 50 (с 1250 и строкой правки 1260) — сходится; 1600 нет — «пустая цель» + assert [(f.relation, f.column_key, f.column_index) for f in result.checks.relations_failed] == [ + ("1600 = 1100 + 1200", "c1", 2) + ] + assert result.value_needs_review[("l1230", "c1")] is False + assert result.value_needs_review[("ldet", "c1")] is False + assert result.line_needs_review["l1250"] is False + assert result.detail_parents == {"ldet": "l1250"} + assert (result.edition, result.form_variant, result.status) == (Edition.FSBU4, FormVariant.FULL, + DocStatus.NEEDS_CHECK) + + +def test_code_change_switches_variant(rules: Rules) -> None: + simplified = form(sl("a", 0, "1150", "50"), sl("b", 1, "1600", "50"), sl("c", 2, "1300", "50"), + sl("d", 3, "1700", "50")) + assert recompute(simplified, rules).form_variant is FormVariant.SIMPLIFIED + edited = form(sl("a", 0, "1100", "50", code_fixed=True), sl("b", 1, "1600", "50"), sl("c", 2, "1300", "50"), + sl("d", 3, "1700", "50")) + result = recompute(edited, rules) + assert result.form_variant is FormVariant.FULL + assert result.status is DocStatus.NEEDS_CHECK + + +def test_edition_equals_choose_edition(rules: Rules) -> None: + stored = form(sl("a", 0, "1110", "10"), sl("b", 1, "1120", "5", conf=60.0), sl("c", 2, "1130", "5")) + pairs = [("1110", True), ("1120", False), ("1130", True)] + expected = choose_edition(DocumentType.BALANCE, pairs, date(2025, 9, 30), rules) + assert recompute(stored, rules).edition is expected is Edition.RAS_66N + + +def test_accepted_and_empty_edit_line(rules: Rules) -> None: + broken = form(sl("a", 0, "1600", "100"), sl("b", 1, "1700", "90"), sl("e", None, "1220", None)) + assert recompute(broken, rules).status is DocStatus.NEEDS_CHECK + assert recompute(replace(broken, accepted=True), rules).status is DocStatus.DONE +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_recompute.py -v` +Expected: FAIL — `ImportError: cannot import name 'StoredColumn'` + +- [ ] **Step 3: Implement `recompute.py`** по правилам выше; экспорт в `__init__.py`. Строки сортируются по + `(row_index is None, row_index)` до `evaluate`; `evaluate(..., accepted=form.accepted)`. + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: точка входа пересчёта формы по сохранённым данным (правки, подтверждения, строки правки)" +``` + +--- + +### Task 19: Перевод выхода в форму контракта v2.0 и вход статуса из документа контракта + +**Files:** +- Create: `$R/arm_statements/contract.py` +- Modify: `$R/arm_statements/__init__.py` (экспорт) +- Test: `$R/arm_statements/tests/test_contract.py` + +**Interfaces:** +- Consumes: `StatementDocument`, `StatementWarning`, `HEADER_FIELDS`, `StatusInput`, `StatusLine`, `StatusValue`, + `status_input` (задача 16), `DocumentType`, `LineStatus`, `Severity`, `PeriodKind`. +- Produces (`arm_statements/contract.py`; один перевод на оба сервиса — `contract_map` / `statements/contract.py` хостов + не пишутся, P1-26): + - `warning_to_contract(warning: StatementWarning, *, file_level: bool = False) -> dict[str, Any]` — `{code, severity, + message}` (значения — строки) и только непустые необязательные ключи: `line_refs` (список, если не пуст), + `column_index` (если не `None`), `field` (если не `None`); `locator` — только при `file_level=True` и не `None` + (`fileWarning`; у предупреждения документа место — `locator` документа, ключа нет). Схема v2.0 не допускает `null` + у `column_index` и `field` — поэтому ключи опускаются, а не пишутся `null` + - `to_contract(document: StatementDocument) -> dict[str, Any]` — `statementDocument` схемы v2.0 как json-совместимый + `dict` (хост валидирует его своей моделью или схемой): `document_type`, `form_variant` (`null` у `DISCLOSURE`), + `locator`, `status`, `warnings` (`warning_to_contract`, без `locator`); `header` — плоский: `form_name`, по каждому + полю `HEADER_FIELDS` три ключа `<поле>` = `value` (дата — ISO), `<поле>_text` = `text`, `<поле>_confidence` = + `confidence` (все 30 ключей, `null` где нет), `needs_review_fields` (список); `columns[] {column_index, label, + label_confidence, period_date, period_kind}`; `lines[] {row_index, ref, line_status, line_code, line_code_text, + line_code_confidence, line_name, line_name_confidence, parent_ref, needs_review, values[] {column_index, amount, + string_value, text, confidence, is_recognized, needs_review}}`; `checks` — только если не `None`: + `{relations_failed[], soft_relations_failed[]}` из `{column_index, relation, codes, expected, actual}` (`column_key` + отбрасывается; `column_index is None` — `ValueError`: у сервиса номер колонки есть всегда). Деньги — строка + `format(x, "f")` или `null`; перечисления — значения; уверенности — `float`/`null`. `ref` ячеек шапки и `evidence` + в выходе нет (их нет в `StatementDocument`) + - `status_input_from_contract(document: Mapping[str, Any]) -> StatusInput` — **для ядра**: то же правило, что + `status_input` (задача 16), по `statementDocument` контракта v2.0 (`dict` после разбора json или `model_dump(mode="json")` + модели хоста; отсутствующий необязательный ключ и `null` — одно и то же): `layout_found = len(lines) > 0`; + `has_organization_or_inn` — `header.organization_name` или `header.inn` непусты; `period_known` — + `header.report_date` не `null` или есть колонка с `period_kind == "absolute"` и непустым `period_date`; + `needs_review_fields`; строки и значения — `(LineStatus(line_status), needs_review)`; `hard_failures = + len(checks.relations_failed)` (нет `checks` — 0); `accepted = False`; `warning_severities` — `Severity(w.severity)`. + Схему не проверяет: битый документ — `KeyError` / `ValueError` (ядро валидирует результат до вызова) + +Свойство, которое закрепляют тесты: для любого документа пакета `status_input_from_contract(to_contract(doc)) == +status_input(doc)` и `document_status(status_input(doc)) is doc.status` — ядро, проверяя `document.status` результата, +получает ровно то, что посчитал сервис. + +- [ ] **Step 1: Write the failing test** + +```python +import json +from dataclasses import replace +from decimal import Decimal as D + +import pytest + +from arm_statements import ( + DocStatus, DocumentType, document_status, make_warning, normalize_candidate, status_input, + status_input_from_contract, to_contract, warning_to_contract, +) +from arm_statements.loader import Rules +from arm_statements.model import FailedRelation, StatementChecks +from arm_statements.tests.builders import hdr, ocr + +HEAD = ["Наименование показателя", "Код", "На 30.09.2025"] +FULL_HEADER = hdr(organization_name="ООО «Пример»", inn=("7700000000", 80.0), report_date="30.09.2025", + units="тыс. руб.") +HEADER_KEYS = {"form_name", "needs_review_fields"} | { + f"{name}{suffix}" + for name in ("organization_name", "inn", "report_date", "units", "okud", "okpo", "okopf", "okfs", "okei", "address") + for suffix in ("", "_text", "_confidence") +} + + +def balance(rules: Rules, *rows: list[str]): + cand = ocr([HEAD, *rows], header=FULL_HEADER, type_hint=DocumentType.BALANCE, title="Бухгалтерский баланс") + doc = normalize_candidate(cand, rules).document + assert doc is not None + return doc + + +def test_document_in_contract_form(rules: Rules) -> None: + doc = balance(rules, ["Запасы", "1210", "60"], ["Итого II", "1200", "60"], ["НИ", "", "7"]) + data = json.loads(json.dumps(to_contract(doc), ensure_ascii=False)) + assert set(data["header"]) == HEADER_KEYS + assert (data["header"]["form_name"], data["header"]["inn"], data["header"]["inn_confidence"]) == ( + "Бухгалтерский баланс", "7700000000", 80.0) + assert data["header"]["report_date"] == "2025-09-30" and data["header"]["okei"] is None + assert data["lines"][0]["values"][0]["amount"] == "60.00" + assert {ln["line_status"] for ln in data["lines"]} >= {"ok"} + assert "column_key" not in json.dumps(data) and "row:" not in json.dumps(data["header"]) + assert "locator" not in json.dumps(data["warnings"]) + assert set(data["checks"]) == {"relations_failed", "soft_relations_failed"} + + +def test_empty_target_is_null_in_contract(rules: Rules) -> None: + doc = balance(rules, ["Запасы", "1210", "60"], ["Итого II", "1200", "60"], ["Итого I", "1100", "40"]) + failed = to_contract(doc)["checks"]["relations_failed"] + target = next(f for f in failed if f["relation"] == "1600 = 1100 + 1200") + assert (target["expected"], target["actual"], target["column_index"]) == (None, "100.00", 2) + + +def test_no_lines_no_checks_key(rules: Rules) -> None: + doc = normalize_candidate(ocr([["Итого", "15 000"]], type_hint=DocumentType.BALANCE, header=FULL_HEADER), + rules).document + assert doc is not None and "checks" not in to_contract(doc) and to_contract(doc)["lines"] == [] + + +def test_relation_without_column_index_is_error(rules: Rules) -> None: + doc = balance(rules, ["Запасы", "1210", "60"], ["Итого II", "1200", "60"]) + bad = FailedRelation(None, "c1", "1600 = 1700", ("1600", "1700"), D("1"), None) + with pytest.raises(ValueError, match="column_index"): + to_contract(replace(doc, checks=StatementChecks((bad,), ()))) + + +def test_warning_keys(rules: Rules) -> None: + w = make_warning("ROW_SPLIT", "разлеплено", rules, line_refs=("row:3#1", "row:3#2"), locator="file:a.json") + assert warning_to_contract(w) == {"code": "ROW_SPLIT", "severity": "info", "message": "разлеплено", + "line_refs": ["row:3#1", "row:3#2"]} + lost = make_warning("TABLE_LOST", "таблица", rules, locator="file:a.json;table:1") + assert warning_to_contract(lost, file_level=True) == {"code": "TABLE_LOST", "severity": "error", + "message": "таблица", "locator": "file:a.json;table:1"} + + +def test_status_input_roundtrip(rules: Rules) -> None: + doc = balance(rules, ["Запасы", "1210", "60"], ["Итого II", "1200", "60"], ["НИ", "", "7"]) + data = status_input_from_contract(json.loads(json.dumps(to_contract(doc), ensure_ascii=False))) + assert data == status_input(doc) + assert document_status(data) is doc.status is DocStatus.NEEDS_CHECK +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `.venv/bin/pytest arm_statements/tests/test_contract.py -v` +Expected: FAIL — `ImportError: cannot import name 'to_contract'` + +- [ ] **Step 3: Implement `contract.py`** по Interfaces; экспорт `to_contract`, `warning_to_contract`, + `status_input_from_contract`, `status_input` в `__init__.py`. Ключи `header` строятся циклом по `HEADER_FIELDS` (новое + поле шапки — одна правка в `model.py`). + +- [ ] **Step 4: Run tests** + +Run: `make check` +Expected: всё зелёное. + +- [ ] **Step 5: Commit** + +```bash +git -C "$R" add arm_statements +git -C "$R" commit -m "feat: перевод документа и предупреждений в форму контракта v2.0, вход статуса из документа контракта" +``` + +--- + +### Task 20: Golden на обезличенных входах (Review Focus), README, итоговая проверка + +**Files:** +- Create: `$R/arm_statements/tests/golden/{aep3_balance,aep3_income,glued_balance,kody_title_balance,npz_income}.candidate.json` + и одноимённые `*.expected.json` +- Create: `$R/arm_statements/tests/test_golden.py` +- Modify: `$R/README.md` (раздел «Публичный API» — копия раздела этого плана; «Как менять словари»; «golden-update») +- Modify: `$R/arm_statements/__init__.py` (окончательный `__all__`) + +**Interfaces:** +- Consumes: `normalize_candidate`, `to_jsonable`, `candidate_from_dict`, `builders`, `to_contract`, + `status_input`, `status_input_from_contract`, `document_status`. +- Produces: ничего нового для других задач. + +Кандидаты собираются один раз скриптом в теле теста-генератора (`builders` → `to_jsonable` → файл) и дальше лежат +файлами; названия, ИНН и адреса — обезличенные, суммы — синтетические, но мусорный текст ячеек — дословно из fixtures +(Review Focus). Эталон `*.expected.json` — `to_jsonable(normalize_candidate(...))`; при `GOLDEN_UPDATE=1` тест +переписывает эталоны и падает с напоминанием просмотреть diff; без переменной — сравнивает. Ключевые факты каждого +входа проверяются явными `assert` (эталон ловит дрейф, `assert` — смысл). + +- [ ] **Step 1: Write the failing test** + +```python +import json +import os +from decimal import Decimal as D +from pathlib import Path + +import pytest + +from arm_statements import ( + document_status, normalize_candidate, status_input, status_input_from_contract, to_contract, +) +from arm_statements.loader import Rules +from arm_statements.model import DocStatus, LineStatus, StatementDocument +from arm_statements.serialize import candidate_from_dict, to_jsonable + +GOLDEN = Path(__file__).parent / "golden" +FULL_CONFIDENCE = 100.0 +NAMES = ["aep3_balance", "aep3_income", "glued_balance", "kody_title_balance", "npz_income"] + + +def run(name: str, rules: Rules) -> StatementDocument: + data = json.loads((GOLDEN / f"{name}.candidate.json").read_text(encoding="utf-8")) + result = normalize_candidate(candidate_from_dict(data), rules) + assert result.document is not None + return result.document + + +@pytest.mark.parametrize("name", NAMES) +def test_golden_snapshot(name: str, rules: Rules) -> None: + actual = to_jsonable(run(name, rules)) + target = GOLDEN / f"{name}.expected.json" + if os.environ.get("GOLDEN_UPDATE") == "1": + target.write_text(json.dumps(actual, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + pytest.fail(f"эталон {target.name} пересобран — просмотрите diff и запустите без GOLDEN_UPDATE") + assert actual == json.loads(target.read_text(encoding="utf-8")) + + +@pytest.mark.parametrize("name", NAMES) +def test_status_is_function_of_contract_document(name: str, rules: Rules) -> None: + doc = run(name, rules) + contract = json.loads(json.dumps(to_contract(doc), ensure_ascii=False)) + assert status_input_from_contract(contract) == status_input(doc) + assert document_status(status_input(doc)) is doc.status + + +def test_aep3_junk_codes(rules: Rules) -> None: # Review Focus 1 + doc = run("aep3_balance", rules) + by_text = {ln.line_code_text: ln for ln in doc.lines} + assert (by_text["| 140"].line_status, by_text["| 140"].line_code) == (LineStatus.OK, "1140") + confidence = by_text["| 140"].line_code_confidence + assert confidence is not None and confidence < FULL_CONFIDENCE + for junk in ("НИ", "изо :"): + assert by_text[junk].line_status is LineStatus.DETAIL and by_text[junk].parent_ref is not None + refs = [ln.ref for ln in doc.lines] + assert len(refs) == len(set(refs)) and [ln.row_index for ln in doc.lines] == list(range(len(doc.lines))) + + +def test_aep3_dirty_income_amounts(rules: Rules) -> None: # Review Focus 2 + doc = run("aep3_income", rules) + values = {v.text: v for ln in doc.lines for v in ln.values if v.text} + assert (values["(7744536}"].amount, values["(7744536}"].needs_review) == (D("-7744536"), False) + assert (values["211(1 |"].amount, values["211(1 |"].needs_review) == (None, True) + assert (values["42831 1"].amount, values["42831 1"].needs_review) == (D("428311"), False) + assert doc.status is DocStatus.NEEDS_CHECK + + +def test_glued_rows(rules: Rules) -> None: # Review Focus 3 + doc = run("glued_balance", rules) + split = [ln for ln in doc.lines if "#" in ln.ref] + assert [(ln.line_code, ln.values[0].amount) for ln in split] == [("1500", D("917775")), ("1700", D("2371333"))] + assert [w.code for w in doc.warnings if w.code == "ROW_SPLIT"] == ["ROW_SPLIT"] + assert not [v for ln in doc.lines for v in ln.values if v.needs_review] + + +def test_kody_title_block(rules: Rules) -> None: # Review Focus 4 + doc = run("kody_title_balance", rules) + assert "12267" not in [ln.line_code for ln in doc.lines] + assert doc.header.okopf.value == "12267" and doc.status is DocStatus.DONE + + +def test_npz_continuation_single_code(rules: Rules) -> None: # Review Focus 5 + doc = run("npz_income", rules) + (line_2400,) = [ln for ln in doc.lines if ln.line_code == "2400"] + assert [v.column_index for v in line_2400.values] == [c.column_index for c in doc.columns] + assert all(v.amount is not None for v in line_2400.values) + assert "," in doc.locator and not [w for w in doc.warnings if w.code.startswith("SEGMENT_")] + assert doc.status is DocStatus.DONE +``` + +- [ ] **Step 2: Build golden inputs** + +Временный генератор (в `arm_statements/tests/test_golden.py` не остаётся; в коммит не идёт): собрать пять кандидатов +`builders` и записать `to_jsonable(...)` в `golden/<name>.candidate.json`. Содержание: +- `aep3_balance` — OCR ББ на бланке 66н (шапка с ИНН «3|1 21 2|» уверенностью 80 и организацией «НКО «Пример»»), строки + 1110, 1150, «| 140» (уверенность 100), «НИ» с суммой, «изо :» с `'3'` уверенностью 0, 1100, 1210…1200, 1600, + раздел III НКО (повтор 1310…1300), 1700; суммы сходятся. +- `aep3_income` — OCR ОФР: 2110, 2120 `"(7744536}"`, 2100 сходится по модулю; 2210 `"(100)"`, 2220 `"(200)"`, 2200; + строка 2310 с `"211(1 |"`; 2320 `"42831 1"` и 2300 так, что `2300 = …` сходится только при 428311 (ячейка 2310 + `null` — член пуст, соотношение всё равно посчитано: подобрать 2300 = сумма прочих членов). +- `glued_balance` — OCR ББ: …, строка с кодом `"1500\n1700"`, суммами `"917 775\n2 371 333"`, наименованием + `"Итого по разделу V\nБАЛАНС"`; 1510 + 1520 = 917775, 1300 + 1400 + 1500 = 2371333, 1600 = 2371333. +- `kody_title_balance` — Excel ББ одного листа «Баланс»: титульный блок справа (столбец F: «Коды», 0710001, 30 | 09 | + 2025, ОКПО, ИНН 7700000000, ОКОПФ 12267, ОКФС 16, ОКЕИ 384), таблица без подписи «Код» в столбце C (≥ 6 кодов), шапка + `HeaderCells` из адаптера (организация, ИНН, дата, ОКОПФ «12267», ОКЕИ «384» без текста единиц). +- `npz_income` — Excel ОФР на листах «ОФР» (шапка, «Код» в столбце D, 2110…2300, 2410, 2460) и «ОФР_2» (без шапки, + одна строка 2400 с кодом в столбце B и суммами в C, D); суммы сходятся, `2400 = 2300 + 2410 + 2460` точно. + +Затем `GOLDEN_UPDATE=1 .venv/bin/pytest arm_statements/tests/test_golden.py -k snapshot` и просмотр каждого +`*.expected.json` глазами против Review Focus. + +- [ ] **Step 3: Run tests to verify state** + +Run: `.venv/bin/pytest arm_statements/tests/test_golden.py -v` +Expected: PASS всех тестов. Если факт-тест падает — это находка в коде задач 8–17, а не повод править тест: чинить +код, пересобрать эталон, повторить. + +- [ ] **Step 4: README и `__all__`** + +`__init__.py` экспортирует ровно имена раздела «Публичный API пакета» ниже; тест `test_public_api` в +`arm_statements/tests/test_golden.py` не нужен — добавить в `arm_statements/tests/test_model.py`: + +```python +import arm_statements + +PUBLIC = { + "Source", "DocumentType", "FormVariant", "Edition", "PeriodKind", "LineStatus", "Severity", "DocStatus", + "EnvelopeStatus", "SignForm", "CellValue", "Cell", "Evidence", "HeaderCells", "HEADER_FIELDS", "StatementWarning", + "FormCandidate", "HeaderField", "DateField", "StatementHeader", "StatementColumn", "StatementValue", + "StatementLine", "FailedRelation", "StatementChecks", "StatementDocument", "CandidateResult", + "NormalizationResult", "Rules", "RulesError", "load_rules", "default_rules", "normalize_candidate", "normalize", + "parse_header", "type_hint", "continuation_type", "sheet_joins", "parse_code", "parse_amount", "parse_date", + "resolve_relative", + "fix_lookalikes", "make_warning", "StatusInput", "StatusLine", "StatusValue", "document_status", + "envelope_status", "StoredForm", "StoredColumn", "StoredLine", "StoredValue", "StoredWarning", "RecomputeResult", + "recompute", "to_jsonable", "candidate_from_dict", "statements_version", + "WarningCode", "choose_edition", "status_input", "status_input_from_contract", "to_contract", "warning_to_contract", + "code_column", "amount_columns", "normalize_label", "normalize_caption", "is_continuation", +} + + +def test_public_api() -> None: + assert set(arm_statements.__all__) == PUBLIC + assert all(hasattr(arm_statements, name) for name in PUBLIC) +``` + +`README.md`: «Публичный API» (копия раздела ниже), «Как менять словари» (PR сюда; перечень кодов — только из docs, +байтовой копией; новый код предупреждения — `statement_warnings.yaml` + член `WarningCode` + таблица §11.9 docs и +таблица спеки «Шаг 8 — предупреждения»), «golden-update». + +- [ ] **Step 5: Final verification** + +Run: `make check` +Expected: ruff, format, mypy — без ошибок; все тесты зелёные; `git -C "$R" status` чист после коммита; файла +`arm_statements/VERSION` нет. + +- [ ] **Step 6: Commit** + +```bash +git -C "$R" add README.md arm_statements +git -C "$R" commit -m "test: golden на обезличенных входах aep_3, склейки, блока «Коды» 1С и листа-продолжения НПЗ; публичный API" +``` + +--- + +## Публичный API пакета + +Импорт — `from arm_statements import …`. Всё, что не перечислено, — внутреннее (может меняться без координации с +хостами). Все dataclass-ы — `frozen=True, slots=True`; последовательности — `tuple`. Раздел сведён с запросами планов +3, 4, 5 (18.10.2026, P1-21…P1-28): хосты не повторяют правил пакета у себя — ни столбца кодов, ни нормализации меток, ни +перевода в контракт. + +### Перечисления (`StrEnum`, значение — строка контракта) + +| Имя | Значения | +|---|---| +| `Source` | `OCR = "ocr"`, `EXCEL = "excel"` | +| `DocumentType` | `BALANCE`, `INCOME`, `DISCLOSURE` | +| `FormVariant` | `FULL = "full"`, `SIMPLIFIED = "simplified"` | +| `Edition` | `RAS_66N = "66n"`, `FSBU4 = "fsbu4"` | +| `PeriodKind` | `ABSOLUTE = "absolute"`, `CURRENT = "relative:current"`, `PREV_YEAR_END = "relative:prev_year_end"`, `PREV_PREV_YEAR_END = "relative:prev_prev_year_end"`, `PREV_PERIOD = "relative:prev_period"`, `NONE = "none"` | +| `LineStatus` | `OK = "ok"`, `UNREADABLE = "unreadable"`, `MISSING = "missing"`, `DETAIL = "detail"` | +| `Severity` | `ERROR = "error"`, `INFO = "info"` | +| `DocStatus` | `DONE = "done"`, `NEEDS_CHECK = "needs_check"` | +| `EnvelopeStatus` | `DONE = "done"`, `NEEDS_CHECK = "needs_check"`, `ERROR = "error"` | +| `SignForm` | `PARENTHESES = "parentheses"`, `MINUS = "minus"`, `UNSIGNED = "unsigned"` | +| `WarningCode` | по члену на код словаря `statement_warnings.yaml`, имя = значение: `JSON_FAILED`, `TABLE_PARSE_ERROR`, `TABLE_LOST`, `TABLE_SKIPPED`, `CODE_COLUMN_NOT_FOUND`, `SEGMENT_WITHOUT_CODES`, `SHEET_NOT_JOINED`, `CONTINUATION_TYPE_CONFLICT`, `SEGMENT_COLUMNS_MISMATCH`, `COLUMN_WITHOUT_DATE`, `COLUMN_DATE_UNCERTAIN`, `HEADER_ROW_NOT_FOUND`, `TYPE_CONFLICT`, `NO_ORGANIZATION_NO_INN`, `PERIOD_UNKNOWN`, `REPORT_DATE_UNCERTAIN`, `XLSB_SCALE_SUSPECT`, `INN_NOT_FOUND`, `INN_UNCERTAIN`, `ORGANIZATION_NOT_FOUND`, `ORGANIZATION_UNCERTAIN`, `REPORT_DATE_UNPARSED`, `UNITS_NOT_FOUND`, `FILE_TYPE_MISMATCH`, `COLUMN_SKIPPED`, `FRACTIONAL_IN_THOUSANDS`, `XLSB_NO_FORMATS`, `NUMBER_FORMAT_UNPARSED`, `VALUE_OUT_OF_RANGE`, `SHEET_OTHER`, `ROW_SPLIT`, `XLS_NO_CODEPAGE`. Коды `OSV_*` пути ОСВ сюда не входят (их создаёт tabular-parser, спека «Шаг 8 — предупреждения») | + +### Вход шага 1 (строит адаптер хоста) + +```python +CellValue = Decimal | date | bool | None + +Cell(text: str, value: CellValue, confidence: float, ref: str) + # text — как показывает источник; value — у OCR всегда None, у Excel — по числовому формату (Decimal), дата или bool; + # confidence 0–100 (Excel — 100); ref — OCR "row:<row_index ИС>", Excel "<лист>!<A1>" по правилу Excel. + # ref ячеек шапки (HeaderCells) и evidence пакет только читает: в выход шагов 2–8 он не переносится (у HeaderField + # поля ref нет), формат — на усмотрение адаптера (scan-gateway — "data_ocr.<поле ИС>", P5-5) +Evidence(okud: Cell | None = None, title: Cell | None = None) + # title.text без изменений становится StatementHeader.form_name (пустой после strip — None) +HeaderCells(organization_name, inn, report_date, units, okud, okpo, okopf, okfs, okei, address: Cell | None = None) +HEADER_FIELDS: tuple[str, ...] # имена полей HeaderCells по порядку +StatementWarning(code: str, severity: Severity, message: str, line_refs: tuple[str, ...] = (), + column_index: int | None = None, field: str | None = None, locator: str | None = None) +FormCandidate(source: Source, locator: str, type_hint: DocumentType | None, evidence: Evidence, header: HeaderCells, + rows: tuple[tuple[Cell | None, ...], ...], segment_breaks: tuple[int, ...] = (), + warnings: tuple[StatementWarning, ...] = ()) + # rows: позиция ячейки в строке = номер столбца сетки (OCR — column_index ИС, Excel — столбец листа с 0); + # locator: "file:<имя json>;table:<i>" | "sheet:<A>[,<B>…]"; warnings — предупреждения адаптера документу + # (XLSB_NO_FORMATS, NUMBER_FORMAT_UNPARSED, CONTINUATION_TYPE_CONFLICT, SHEET_NOT_JOINED …) +``` + +### Выход шагов 2–8 + +```python +HeaderField(text: str | None, confidence: float | None, value: str | None) +DateField(text: str | None, confidence: float | None, value: date | None) +StatementHeader(organization_name: HeaderField, inn: HeaderField, report_date: DateField, units: HeaderField, + okud: HeaderField, okpo: HeaderField, okopf: HeaderField, okfs: HeaderField, okei: HeaderField, + address: HeaderField, form_name: str | None = None, needs_review_fields: tuple[str, ...] = ()) + # контракт: <поле> = value, <поле>_text = text, <поле>_confidence = confidence; form_name — header.form_name + # (заголовок формы как в источнике, из evidence.title кандидата; не теряется при normalize) +StatementColumn(column_index: int, label: str, label_confidence: float | None, period_date: date | None, + period_kind: PeriodKind) +StatementValue(column_index: int, amount: Decimal | None, string_value: str | None, text: str | None, + confidence: float, is_recognized: bool, needs_review: bool = False) +StatementLine(row_index: int, ref: str, line_status: LineStatus, line_code: str | None, line_code_text: str | None, + line_code_confidence: float | None, line_name: str | None, line_name_confidence: float | None, + parent_ref: str | None, needs_review: bool, values: tuple[StatementValue, ...]) + # values — по всем columns документа, в их порядке +FailedRelation(column_index: int | None, column_key: str, relation: str, codes: tuple[str, ...], + expected: Decimal | None, actual: Decimal | None) + # сервис: column_key = str(column_index); ядро: column_key — StoredColumn.key. + # expected is None ⇔ цель пуста, actual is None ⇔ пусты все члены (контракт: money, null — «цели нет»); такое + # посчитанное соотношение — всегда нарушение (спека «Шаг 7»), P1-21 +StatementChecks(relations_failed: tuple[FailedRelation, ...], soft_relations_failed: tuple[FailedRelation, ...]) +StatementDocument(document_type: DocumentType, form_variant: FormVariant | None, locator: str, + header: StatementHeader, columns: tuple[StatementColumn, ...], lines: tuple[StatementLine, ...], + checks: StatementChecks | None, status: DocStatus, warnings: tuple[StatementWarning, ...]) + # checks is None ⇔ lines == (); form_variant is None ⇔ DISCLOSURE (у BALANCE/INCOME без строк — FULL, P1-19); + # status == document_status(status_input(document)) всегда +CandidateResult(document: StatementDocument | None, file_warnings: tuple[StatementWarning, ...]) +NormalizationResult(documents: tuple[StatementDocument, ...], file_warnings: tuple[StatementWarning, ...]) +``` + +### Функции конвейера и разборов + +```python +normalize_candidate(candidate: FormCandidate, rules: Rules | None = None) -> CandidateResult + # шаги 2–8 одного кандидата; документа нет — file_warnings (TABLE_LOST | TABLE_SKIPPED | SHEET_OTHER) +normalize(candidates: Sequence[FormCandidate], rules: Rules | None = None) -> NormalizationResult + # по порядку; исключение кандидата не перехватывается — хост, которому нужна изоляция кандидатов + # (TABLE_PARSE_ERROR), зовёт normalize_candidate в цикле +recompute(form: StoredForm, rules: Rules | None = None) -> RecomputeResult +parse_header(cells: HeaderCells, rules: Rules) -> StatementHeader # form_name = None (ставит конвейер) +type_hint(okud_text: str | None, title_text: str | None, rules: Rules) -> DocumentType | None +is_continuation(text: str | None, rules: Rules) -> bool # маркер «Форма 0710001 с.2» (любой ОКУД) +continuation_type(text: str | None, rules: Rules) -> DocumentType | None # тип маркера; не маркер или ОКУД не из словаря — None +sheet_joins(form_type: DocumentType, sheet_codes: Sequence[str], sheet_amount_columns: int, + form_amount_columns: int, rules: Rules) -> bool # лист без маркера и шапки (адаптер Excel плана 4) +code_column(rows: Sequence[Sequence[Cell | None]], rules: Rules, *, relaxed: DocumentType | None = None) -> int | None + # столбец кодов сетки — правило (а)/(б) шага 2 (с защитой от блока «Коды»); relaxed=BALANCE|INCOME — ослабленный + # поиск в отрезке этой формы, минимум rules.code_ranges.min_codes_continuation; relaxed=DISCLOSURE — ValueError +amount_columns(rows: Sequence[Sequence[Cell | None]], code_col: int, rules: Rules, *, + source: Source = Source.EXCEL) -> tuple[int, ...] + # столбцы правее code_col, где в строках с кодом есть число — колонки сегмента без заголовка; «число колонок сумм» + # для sheet_joins — len(amount_columns(...)) +parse_code(text: str | None, rules: Rules) -> CodeParse # CodeParse(code: str | None, fixed: bool) +parse_amount(text: str | None, source: Source, rules: Rules) -> AmountParse + # AmountParse(amount: Decimal | None, string_value: str | None, is_recognized: bool, sign_form: SignForm | None) +parse_date(text: str | None, rules: Rules) -> date | None +resolve_relative(kind: PeriodKind, effective: date | None) -> date | None + # дата относительной колонки от эффективной даты (ядро — после правки даты отчёта); effective is None → None; + # ABSOLUTE и NONE → None, не исключение (P1-24) +choose_edition(document_type: DocumentType, codes: Sequence[tuple[str, bool]], effective: date | None, rules: Rules, + *, variant: FormVariant | None = None) -> Edition | None + # редакция формы без полного пересчёта (ядро — при приёме результата 2.0): codes — (код, уверенный) строк ok по + # порядку строк, уверенный ⇔ confidence == 100 или код зафиксирован аналитиком; variant None → по кодам; + # лучшая подпоследовательность перечня (больше уверенных, затем длиннее), ничья — по дате, ничья без даты — None; + # DISCLOSURE и пустые codes — по дате. recompute отдаёт ту же редакцию (P1-25) +fix_lookalikes(text: str, lookalikes: Mapping[str, str], *, anywhere: bool = False) -> str +normalize_caption(text: str | None) -> str # подписи колонок: нижний регистр, ё→е, всё кроме букв, цифр и % → пробел +normalize_label(text: str | None) -> str # метки шапки и заголовки: = normalize_text tabular-parser (P1-22) +make_warning(code: str, message: str, rules: Rules, *, line_refs: Sequence[str] = (), column_index: int | None = None, + field: str | None = None, locator: str | None = None) -> StatementWarning + # code — WarningCode (или строка того же значения); уровень — из словаря statement_warnings.yaml; неизвестный код — + # KeyError (предупреждение с выдуманным кодом не создаётся). Хосты создают JSON_FAILED, TABLE_PARSE_ERROR, + # FILE_TYPE_MISMATCH, XLS_NO_CODEPAGE, XLSB_NO_FORMATS, NUMBER_FORMAT_UNPARSED, CONTINUATION_TYPE_CONFLICT, + # SHEET_NOT_JOINED, SHEET_OTHER только через неё +to_jsonable(obj: object) -> Any # dataclass пакета → json-совместимое значение (golden) +candidate_from_dict(data: Mapping[str, Any]) -> FormCandidate # обратная для кандидата +statements_version(path: Path | None = None) -> str | None # содержимое api/arm_statements/VERSION копии +load_rules(directory: Path | None = None) -> Rules # RulesError при ошибке словаря +default_rules() -> Rules # кэш словарей пакета +``` + +`CodeParse` и `AmountParse` в `__all__` не входят (возвращаются функциями; при необходимости аннотаций — +`arm_statements.codes.CodeParse`, `arm_statements.amounts.AmountParse`). + +### Форма контракта v2.0 (сервисы пишут, ядро читает) + +```python +to_contract(document: StatementDocument) -> dict[str, Any] + # statementDocument схемы v2.0: шапка плоская (form_name, <поле>, <поле>_text, <поле>_confidence для всех + # HEADER_FIELDS, needs_review_fields), деньги — строки format(x, "f") или null, у нарушений нет column_key, + # checks нет ⇔ строк нет, предупреждения — warning_to_contract без locator. Хост валидирует dict своей моделью +warning_to_contract(warning: StatementWarning, *, file_level: bool = False) -> dict[str, Any] + # {code, severity, message} + непустые line_refs / column_index / field; locator — только file_level=True + # (metrics.warnings); null у необязательных ключей не пишется — схема его не допускает +status_input_from_contract(document: Mapping[str, Any]) -> StatusInput + # вход функции статуса по statementDocument контракта (ядро: страховка document.status результата 2.0); + # status_input_from_contract(to_contract(d)) == status_input(d) +``` + +### Статус + +```python +StatusLine(line_status: LineStatus, needs_review: bool) +StatusValue(line_status: LineStatus, needs_review: bool) # line_status — статус строки значения +StatusInput(document_type: DocumentType, layout_found: bool, has_organization_or_inn: bool, period_known: bool, + needs_review_fields: tuple[str, ...], lines: tuple[StatusLine, ...], values: tuple[StatusValue, ...], + hard_failures: int, accepted: bool, warning_severities: tuple[Severity, ...]) + # needs_review на входе уже учитывает правки и подтверждения; значения строк DETAIL функция игнорирует +status_input(document: StatementDocument) -> StatusInput + # layout_found = строки есть; period_known = дата отчёта или колонка absolute с датой; hard_failures = + # len(checks.relations_failed); accepted = False — сервис и ядро собирают вход одним правилом +document_status(data: StatusInput) -> DocStatus +envelope_status(document_statuses: Sequence[DocStatus], file_warnings: Sequence[StatementWarning]) -> EnvelopeStatus + # ERROR при отсутствии документов; код ошибки (RECOGNITION_FAILED / INVALID_DOCUMENT) выбирает хост +``` + +### Пересчёт по сохранённым данным (ядро) + +```python +StoredColumn(key: str, column_index: int | None, period_date: date | None, period_kind: PeriodKind) +StoredValue(column_key: str, amount: Decimal | None, text: str | None, confidence: float | None, + is_recognized: bool, value_fixed: bool) + # text — source_text (у значения правки None); value_fixed — is_modified или is_confirmed +StoredLine(key: str, row_index: int | None, line_status: LineStatus, line_code: str | None, + line_code_confidence: float | None, line_name: str | None, code_fixed: bool, parent_key: str | None, + values: tuple[StoredValue, ...]) + # row_index None — строка правки (в §7 и варианте — пока есть сумма; в структуре и родителях detail — нет); + # code_fixed — line_code_modified или is_confirmed строки +StoredWarning(code: str, severity: Severity) +StoredForm(document_type: DocumentType, effective_date: date | None, has_organization_or_inn: bool, + period_known: bool, needs_review_fields: tuple[str, ...], accepted: bool, + warnings: tuple[StoredWarning, ...], columns: tuple[StoredColumn, ...], lines: tuple[StoredLine, ...]) +RecomputeResult(form_variant: FormVariant | None, edition: Edition | None, checks: StatementChecks, + line_needs_review: Mapping[str, bool], value_needs_review: Mapping[tuple[str, str], bool], + detail_parents: Mapping[str, str | None], status: DocStatus) + # value_needs_review: ключ (StoredLine.key, StoredValue.column_key); detail_parents — только строки DETAIL; + # checks.*.expected / actual — Decimal | None, как у сервиса +``` + +### Словари (`Rules`) — что нужно хостам + +Хосты читают словари только через эти атрибуты; прочие поля `Rules` — внутренние. + +```python +Rules.code_orders.sequence(edition: Edition, form: DocumentType, variant: FormVariant) -> tuple[str, ...] + # перечень кодов бланка с законными повторами (ядру — порядок и состав строк формы); DISCLOSURE → (); + # variant — FormVariant, не None: у формы без варианта хост передаёт FormVariant.FULL (как набор §7) +Rules.code_orders.editions -> tuple[Edition, ...] # порядок редакций перечня: (Edition.RAS_66N, Edition.FSBU4) +Rules.checks.tolerance -> Decimal # Decimal("1") — допуск ±1 (ядру — межформенная проверка) +Rules.code_ranges.min_codes -> int # 3 — «три и более кодов» (столбец кодов, sheet_joins) +Rules.code_ranges.min_codes_continuation -> int # 1 — минимум кодов слабого продолжения (code_column relaxed, sheet_joins) +Rules.headers.labels -> Mapping[str, tuple[re.Pattern[str], ...]] + # ключи ровно: organization, inn, okpo, okopf, okfs, okopf_okfs, okei, units, address, date (LABEL_KEYS) +Rules.headers.form_titles / okud -> Mapping[DocumentType, tuple[re.Pattern[str], ...]] +Rules.headers.okud_code -> re.Pattern[str] +Rules.headers.continuation / auditor / legal_form -> tuple[re.Pattern[str], ...] +Rules.headers.code_caption / name_caption / explanations_caption -> re.Pattern[str] +Rules.headers.column_exclusions -> tuple[re.Pattern[str], ...] +Rules.periods.relative -> tuple[tuple[PeriodKind, re.Pattern[str]], ...] +Rules.lookalikes -> Mapping[str, str] # буквы-двойники +Rules.warnings -> Mapping[str, WarningRule] # WarningRule(code, severity, scope: "document" | "file") +``` + +Какой текст подаётся шаблонам (`re.search`), — правило словаря, хост его не выбирает (P1-22): + +| Нормализация | Ключи | +|---|---| +| `normalize_label(text)` — пунктуация сохраняется | `headers.form_titles`, `okud`, `okud_code`, `continuation`, `labels.*`, `auditor`, `legal_form` | +| `normalize_caption(text)` — пунктуация → пробел | `headers.code_caption`, `name_caption`, `explanations_caption`, `column_exclusions`, `units.spellings`, `periods.relative` | + +--- + +## Решения автора плана без обсуждения (P1-n) + +Контроллер переносит их в `design.md` docs, раздел `## Decisions made without discussion` (дата 18.10.2026). + +- **P1-1.** Конфиг линтеров пакета — только в корневом `pyproject.toml` репозитория `arm-statements` (в дерево + `arm_statements/` не входит и в хост не копируется): `ruff.toml` внутри `arm_statements/` по иерархии ruff перекрыл + бы конфиг хоста, а спека требует «конфиг хоста применяется к копии». Код пакета написан под самый строгий конфиг, так + что он проходит любой хост. Цена: «конфиг линтеров вместе с копией» (формулировка спеки) читается как «код, + проходящий конфиг», а не как файл; см. O-1. +- **P1-2.** Версии инструментов пакета — пины scan-gateway (`ruff==0.16.10`, `mypy==2.4.0`, `pytest==9.1.1`): это + единственный хост с явными пинами; у tabular-parser и arm-core `>=`, в их окружениях встречаются ruff 0.16.10–0.17.1. + Цена: новая версия ruff в хосте может найти новое замечание в копии; см. O-2. +- **P1-3.** `PLR2004` в тестах пакета не отключается: `per-file-ignores` хостов (`tests/**` у scan-gateway) на копию + `api/arm_statements/tests/` не распространяются. Цена: числа в тестах — кортежами и `D("…")`. +- **P1-4.** Ничья редакций при `effective = None` — `edition = None`, неуверенный код подтверждён только при + подтверждении в обеих редакциях. Цена: лишняя подсветка кода у формы без даты (она и так `needs_check` — + `PERIOD_UNKNOWN`). +- **P1-5.** «Книга xlsb без числовых форматов» шаг 4 узнаёт по предупреждению адаптера `XLSB_NO_FORMATS` в + `FormCandidate.warnings` — отдельного поля у кандидата спека не задаёт. Цена: адаптер Excel обязан класть это + предупреждение в кандидат каждой формы книги xlsb. +- **P1-6.** У `DISCLOSURE` предупреждения шапки не выдаются, статус всегда `done` (спека: «`DISCLOSURE` — `done` без + условий»). Цена: пустая шапка пояснений не видна предупреждением. +- **P1-7.** Код Excel из текстовой ячейки с заменой двойника — неуверенный (≤ 99) и проходит структурную проверку, как + OCR; «у Excel `needs_review` по структуре не ставится» прочитано как «у чистых кодов Excel» (уверенность 100). Цена: + редкий текстовый код «I1О0» в Excel может подсветиться. +- **P1-8.** Строка правки (`row_index = None`) у ядра участвует в §7 и в выборе варианта, только если у неё есть сумма; в + структурной последовательности и как родитель `detail` — нет (позиции в файле нет). Цена: код правки не служит якорем + для подтверждения соседних неуверенных кодов. +- **P1-9.** Двухстрочный заголовок склеивается, когда подпись верхней строки отдельно — не период, а «верхняя + + нижняя» — период («На 30 сентября» + «2025 г.»; нижняя «2025 г.» сама по себе дала бы ошибочное 31.12.2025). Цена: + заголовок в два ряда без даты не склеивается (колонка всё равно остаётся по числам); если над строкой заголовка + стоит посторонний текст, который вместе с нижней подписью случайно читается как период, подпись склеится. +- **P1-10.** Текстовая сумма без пробелов длиннее 16 целых цифр — `amount = null`, `string_value` = текст, + `is_recognized = false`, без `VALUE_OUT_OF_RANGE` (предупреждение — только для числового `value` Excel, как в спеке). +- **P1-11.** `COLUMN_DATE_UNCERTAIN` проверяется только у абсолютных подписей: относительные даты вычисляются от + эффективной даты и в ожидаемые входят по построению. +- **P1-12.** Поля шапки, правила которых спека не задаёт: `okpo`, `okopf`, `okfs` — только цифры; `okud` — первое + совпадение `0710\d{3}`; `organization_name`, `address` — текст без пробелов по краям. +- **P1-13.** В словарь пакета `statement_headers.yaml` переезжают и метки шапки Excel (`labels`, `auditor`, + `legal_form`) — proposal: «`statement_headers.yaml` переезжает в пакет»; у tabular-parser не остаётся своего словаря + форм. Цена: в общем словаре есть данные, которыми пользуется один адаптер. +- **P1-14.** «Под подписью» в правиле (а) столбца кодов — ячейки столбца ниже подписи до конца сегмента. +- **P1-15.** `FailedRelation` несёт и `column_index`, и `column_key` — чтобы ядро получало нарушения в своих ключах + колонок без обратного сопоставления. +- **P1-16.** Перечень кодов копируется из ветки docs `feat/116-unified-normalization` до слияния docs PR #117. +- **P1-17.** Столбец правее кода без подписи, но с числами, в сегменте с заголовком — колонка с `label = ""`, + `period_kind = none` и `COLUMN_WITHOUT_DATE` (`error`): «числовая колонка без разобранной подписи» по таблице уровней. +- **P1-18.** Заголовок есть у одного из сегментов, но не у первого — колонки документа задаёт первый сегмент с + заголовком (спека: «остальные сегменты берут его колонки»), `column_index` — номера столбцов этого сегмента. +- **P1-19.** Документ `BALANCE` / `INCOME` без строк (`CODE_COLUMN_NOT_FOUND`) получает `form_variant = full`: в схеме + v2.0 `form_variant` обязателен и `null` только у `DISCLOSURE`. + +- **P1-20.** При `accepted` (у ядра) правило «`needs_review` у всех ячеек несошедшегося жёсткого соотношения» не + применяется: иначе акцепт снимал бы нарушение со статуса, а подсвеченные им ячейки держали бы `needs_check` через + условие «нет значений с `needs_review`» (спека: «`accepted` ⇒ жёсткие нарушения статус не держат»). У сервиса + `accepted` всегда ложно. Цена: после акцепта ячейки несошедшегося соотношения не подсвечиваются (нарушение видно в + `checks`). + +- **P1-21.** `FailedRelation.expected` / `actual` (и `RelationOutcome`) — `Decimal | None`: `None` — цель или все члены + пусты; такое посчитанное соотношение — всегда нарушение (допуск не применяется). Контракт v2.0 так и задаёт + (`expected` — `money`, «`null` — цели нет»; `actual` — `money`), агрегат ядра тоже не превращает пустое в 0 (план 3, + Review Focus 2). Цена: хосты и ядро обрабатывают `None` в тексте нарушения («пусто»); при ошибке — `0` вместо пустоты + прятал бы «пустую цель» в допуск ±1 (`1600 = 0` при пустом `1700` считалось бы сошедшимся). +- **P1-22.** Две публичные нормализации текста: `normalize_label` (дословно `normalize_text` tabular-parser — пунктуация + сохраняется; под неё записаны перенесённые из tabular-parser метки шапки, заголовки, ОКУД, маркер продолжения) и + `normalize_caption` (подписи колонок, единицы, относительные периоды). Таблица «ключ словаря → нормализация» — в + разделе API; `type_hint` и `is_continuation` пакета пользуются `normalize_label`. Цена: новый синоним пишется под + нормализацию своего ключа; ошибка — синоним молча не срабатывает (ловят тесты пакета на синтетике и покрытие словарей + в tabular-parser, O-8 плана 4). +- **P1-23.** Новый код `XLS_NO_CODEPAGE` (`info`, файл): книга xls без кодовой страницы — заметка читателя, относящаяся ко + всей книге (и к формам, и к ОСВ). Строки добавлены в таблицу спеки «Шаг 8 — предупреждения» и §11.9 `05-domain-rules.md` + (правило спеки: «новый код вводится словарём пакета и таблицей §11»). Цена: tabular-parser выдаёт его в + `metrics.warnings` вместо `OSV_XLS_NO_CODEPAGE` в каждом документе ОСВ (план 4, P4-13); экран показывает его в карточке + файла, а не у листа ОСВ. +- **P1-24.** `resolve_relative(ABSOLUTE | NONE, …)` → `None`, а не исключение: у таких колонок дата не вычисляется, а ядро + перебирает колонки формы одним циклом. Цена: вызов с неверным видом не заметен (ядро зовёт функцию только для + `relative:*`, план 3 B2). +- **P1-25.** `choose_edition(document_type, codes: Sequence[tuple[str, bool]], effective, rules, *, variant=None)` вместо + запрошенной `choose_edition(effective, codes, rules)`: перечень кодов зависит от формы и варианта, а «лучшая + подпоследовательность» по спеке сравнивает сначала число **уверенных** кодов — без типа и признака уверенности + результат расходился бы с `recompute`. `confirm_codes` и `recompute` берут редакцию этой же функцией. Цена: ядро + передаёт пары `(код, уверенный)`, собранные из строк результата (`line_code_confidence == 100`). +- **P1-26.** Перевод выхода в форму контракта v2.0 (`to_contract`, `warning_to_contract`) — в пакете, а не в каждом + сервисе: одно уплощение шапки, одно правило «необязательные ключи без `null`», один отброс `column_key`. Пакет теперь + знает форму `statementDocument` v2.0. Цена: новый MAJOR контракта меняет и пакет (синхронизация копий — план 2), а + модель контракта хоста валидирует `dict` пакета — расхождение видно тестом схемы хоста, а не пакета. +- **P1-27.** Статус документа — функция документа: `status_input(StatementDocument)` (конвейер считает им `status`) и + `status_input_from_contract(dict)` для ядра (страховка `document.status` результата 2.0); свойство + `status_input_from_contract(to_contract(d)) == status_input(d)` закреплено тестами задач 19 и 20. `layout_found` + выводится из «строки есть» (строк нет ⇔ столбец кодов не найден, задача 17 п. 2). Цена: если в будущем появится + документ со столбцом кодов, но без строк, функции нужен явный признак раскладки. +- **P1-28.** `code_column(rows, rules, *, relaxed)` и `amount_columns(rows, code_col, rules, *, source)` — публичные + обёртки шага 2 над сеткой `Cell` (адаптер Excel строит её из листа); минимум ослабленного поиска и слабого продолжения + — данные словаря (`min_codes_continuation: 1`, был литерал «минимум один» в коде шага 2 и `min_codes_continuation` + tabular-parser). `relaxed` задаётся типом формы, а не внутренним `CodeSection`. Цена: адаптеру нужна сетка `Cell` листа, + чтобы спросить столбец кодов (у tabular-parser она уже есть — `grid_rows` плана 4). +- **P1-29.** `form_name` — поле `StatementHeader` (в контракте он в `header`), а не `StatementDocument`; ставит конвейер из + `evidence.title.text` как есть, `parse_header` оставляет `None`. Цена: хост, собирающий шапку `parse_header` сам (без + конвейера), заголовка не получит — таких вызовов в планах нет. +- **P1-30.** Коды предупреждений — `WarningCode` (`StrEnum`) в `issues.py`; загрузчик требует равенства множества кодов + словаря и перечисления; `make_warning` с неизвестным кодом — `KeyError`. Цена: новый код — две правки в пакете + (словарь + член перечисления) и строка §11.9 docs; опечатку в коде хоста ловит mypy (член перечисления), а строковый + литерал — только `KeyError` при вызове. +- **P1-31.** `Rules.code_orders.sequence(…, DISCLOSURE, …)` → `()`, а не `KeyError`: у пояснений перечня нет, и + структурная проверка, и проверки ядра по редакции к ним не применяются. Цена: вызов для пояснений молча даёт пустой + перечень. + +## Открытые вопросы (O-n) + +- **O-1.** Спека: копия лежит «вместе с … конфигом линтеров»; design Р-1: «конфиг пакета подключается к `pytest` и + `ruff` хоста как include». План (P1-1) не кладёт конфиг в дерево пакета. Нужен ли хостам файл конфига пакета (и как + он подключается без перекрытия конфига хоста) — решается в плане 2 (`sync-statements`, `quality-checks`). +- **O-2.** Версии ruff/mypy в хостах разные (tabular-parser и arm-core — `>=`, scan-gateway — `==0.16.10`); `ruff + format --check` копии на другой версии ruff может разойтись со стилем пакета. Предложение для плана 2: хосты + закрепляют те же версии, что пакет. +- **O-3.** `pytest` хостов: `norecursedirs`/`testpaths` должны включать `arm_statements/tests` (спека требует), а + `pytest.ini` хоста задаёт `env` (`APP_ENV=test`) — тестам пакета он не нужен и не мешает; подтверждение — в плане 2. +- **O-4.** Dockerfile хостов должен копировать `api/arm_statements/rules/*.yaml` в образ (словари читаются с диска) — + в план 2 или планы сервисов. +- **O-5.** Закрыт 18.10.2026: план 3 сверен с разделом «Публичный API пакета» (его «Что ядро вызывает у пакета» + использует `recompute`, `RecomputeResult`, `choose_edition`, `status_input_from_contract`, `resolve_relative`, + `Rules.code_orders.*`, `Rules.checks.tolerance`; уверенности — `float | None`, перечисления — `StrEnum` пакета, + `FailedRelation.expected` / `actual` — `Decimal | None`, P1-21). diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-2-sync-statements.md b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-2-sync-statements.md new file mode 100644 index 0000000..63f7679 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-2-sync-statements.md @@ -0,0 +1,716 @@ +# План 2 — `sync-statements` и `statements-check`: копия пакета `arm_statements` в хостах. План реализации + +> **Для исполнителей-агентов:** обязательный навык — superpowers:subagent-driven-development (в плане 9 задач: по +> `~/.claude/CLAUDE.md` — SDD, контроллер — фоновый сабагент на модели sonnet; исполнители задач — sonnet: готового кода +> в плане нет, есть интерфейсы, сценарии тестов и правила спеки; финальное ревью каждой ветки — opus). Шаги размечены +> чекбоксами (`- [ ]`). + +**Goal:** скрипт `docs/scripts/sync-statements.sh` раскладывает пакет `arm_statements` из репозитория `arm-statements` +копией в `api/arm_statements/` хостов (arm-core, tabular-parser, scan-gateway) и проверяет свежесть копии (`--check`: +коды `0` / `10` / `20` / `2`); три хоста подключают копию (линтеры, `testpaths`, `statements-check` в `make check-full`). + +**Architecture:** по образцу `scripts/sync-web.sh`: bash-скрипт ведёт git-часть (источник, worktree, issue, PR), а +разбор `pyproject.toml` и YAML делает маленький помощник `scripts/statements_check.py` (stdlib `tomllib` + PyYAML). +Точка синхронизации не ищется по истории, как у `sync-web`, — она записана в `api/arm_statements/VERSION`: копия +сравнивается с деревом `arm_statements/` коммита из `VERSION` (без самого `VERSION`); отличие — прямая правка (`20`). +Три режима раскладки: обычный (новый worktree хоста, issue, проверки, коммит, PR), `--dry-run` (worktree и раскладка без +issue и PR) и `--in-place` (раскладка в рабочее дерево существующего worktree без git-операций — для подключения хоста и +для PR фичи, которому нужна новая версия пакета). Тест — `scripts/test_sync_statements.sh` на синтетических +репозиториях без сети, `tea` и Docker (как `test_sync_web.sh`). + +**Tech Stack:** bash (совместимость с bash 3.2 macOS), git, BSD/GNU `diff`/`tar`, Python 3.11 (`tomllib`), PyYAML +6.0.3, GNU make, `tea` (Gitea), uv (окружения хостов). + +**Spec:** `openspec/changes/unified-statement-normalization/` (arm-ka-docs, ветка `feat/116-unified-normalization`): +`specs/statement-normalization/spec.md` — требования «Пакет общего кода `arm_statements` и его копии» и «`sync-statements` +— копии пакета в потребителях» со сценариями; `specs/quality-checks/spec.md` — `make check` и `make check-full`; +`proposal.md` — «Порядок выкладки»; `dev/checks.md` — «Копия общего кода `arm_statements` и `sync-statements`»; +`context/03-architecture.md` — «Общий код нормализации ББ/ОФР». Справка — `design.md` S-27, S-30, S-58, S-70, Р-1. +План пакета — `plans/2026-10-18-plan-1-arm-statements.md` (структура репозитория, `VERSION`, P1-1…P1-3, O-1…O-4). + +## Предусловия + +- **П-1 (docs, задачи 1–5).** Контроллер: issue в `RSHB/arm-ka-docs` (`tea issues create --login home --repo + RSHB/arm-ka-docs`, заголовок «Скрипт sync-statements: копия пакета arm_statements в хостах (#116)», ссылка на #116 и + этот план); worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/sync-statements/docs` на временной ветке от + `origin/main`, затем ветка `feat/<issue>-sync-statements`; в worktree — `codegraph init`; `make venv`. + `$D` = этот путь. Ветка **не** `feat/116-unified-normalization`: по `proposal.md` (шаг 1) PR скрипта сливается раньше + docs PR #117. +- **П-2 (задача 6).** Правки спеки, `design.md` и раздела «Копия общего кода» `dev/checks.md` — в ветке + `feat/116-unified-normalization` (worktree `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs`), пока + PR #117 открыт; если он уже слит — в ветке П-1 до её PR. +- **П-3 (хосты, задачи 7–9).** Старт — после слияния PR плана 1 в `RSHB/arm-statements` `main` (в `origin/main` есть + `arm_statements/` и `pyproject.toml`) и PR задач 1–5 в docs `main`. Чекаут `arm-statements` — + `/Users/andrey.epifancev/projects/awp-ca/arm-statements` (П-2 плана 1). Зеркальный workspace + `/Users/andrey.epifancev/projects/awp-ca/.wt/statements-adopt/` (`$W`): worktree хостов `$W/arm-core`, + `$W/tabular-parser`, `$W/scan-gateway` (каждый — своя issue и ветка `feat/<issue>-arm-statements-copy` от + `origin/main`), `$W/docs` — **отсоединённый** worktree arm-ka-docs на `origin/main` (в нём ничего не коммитится), симлинки + `fixtures`, `reference-old-arm`, `CLAUDE.md`, `.claude` на основной workspace (`context/00-workspace-layout.md`). В + каждом worktree хоста — `.bounded` (bounded-задача: без OpenSpec change и плана хоста) и `codegraph init`. +- **П-4.** Docs PR #117 для задач 7–9 не обязателен: без `contracts/statement-code-orders.yaml` в docs `statements-check` + даёт `10` (P2-9), это вписывается в описание PR. + +## Global Constraints + +- Копия лежит в `api/arm_statements/` хоста; маркер — `api/arm_statements/VERSION`: полный 40-символьный хэш коммита + `arm-statements` и перевод строки; пишет его только скрипт. В источнике файла `VERSION` нет, в сравнении он не участвует. +- Коды `--check` — дословно спека: `0` — копия равна дереву `arm_statements/` коммита из `VERSION`, это коммит `--ref`, и + копия перечня кодов равна `docs/contracts/statement-code-orders.yaml`; `10` — копия равна дереву коммита из `VERSION`, + но в `--ref` есть более новый, или копия перечня отличается от файла docs (называется файл); `20` — прямая правка + копии, `VERSION` нет в истории источника, зависимость пакета (`[project].dependencies` его `pyproject.toml`) отсутствует + в зависимостях хоста, или (только `arm-core`) код `api/rules/statement_lines.yaml` отсутствует в перечне своей + редакции **копии пакета**; `2` — нет checkout источника или хоста, неизвестный аргумент. Файлы и коды расхождения + печатаются. Если причин несколько — печатаются все, код — наибольший по тяжести (`20` > `10` > `0`). +- Источник по умолчанию — `arm-statements` рядом с workspace: кандидаты `$HERE/../../../../arm-statements` (скрипт в + `.wt/<задача>/docs`), затем `$HERE/../../arm-statements`; ссылка — `origin/main`. Хост — `--host + arm-core|tabular-parser|scan-gateway` (каталог `$WS/<имя>`) или `--host-dir <dir>`. Docs — каталог, где лежит скрипт + (`$HERE/..`), или `--docs <dir>`. +- Gitea-репозитории хостов: `arm-core` → `RSHB/arm-core`, `tabular-parser` → `RSHB/arm-tabular-parser`, `scan-gateway` + → `RSHB/arm-scan-gateway`. `tea` — флаги `--login home --repo …` **после** подкоманды (arm-ka-docs#110). +- `statements-check` хоста входит в `make check-full` и **не** входит в `make check` и пайплайн платформы. +- Скрипты совместимы с bash 3.2 (macOS): без `mapfile`, `declare -A`, `${var,,}`; `set -euo pipefail`; BSD и GNU + `diff`/`tar`/`sed` (без `sed -i`). +- Конфиг линтеров пакета в копию не кладётся (P1-1): к копии применяется конфиг хоста. Правки копии под хост запрещены. +- Пины инструментов во всех хостах — как в пакете: `ruff==0.16.10`, `mypy==2.4.0`, `types-PyYAML==6.0.12.20260906`. + Зависимость пакета `PyYAML==6.0.3` уже есть в `api/requirements.txt` всех трёх хостов (проверено 18.10.2026); + `api/requirements.txt` хостов («платформенные зависимости, не меняем») этим планом не меняется. +- Язык сообщений скриптов, комментариев, коммитов, issue и PR — русский. Коммиты заканчиваются строками + `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>` и + `Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W`; PR — строкой + `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. Слияние — только по команде человека. +- Находки и решения несут маркеры (`T<n>.<k>`, `C-n`/`I-n`/`M-n`, `R-n`, `O-n`); решения этого плана — `P2-n` (раздел в + конце); задача 6 переносит их в `design.md`. + +## Review Focus + +1. **Кэши в копии после `make check` хоста** — `__pycache__/`, `*.pyc`, `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/`, + `.DS_Store` внутри `api/arm_statements/` не делают копию «прямой правкой»: `--check` = `0`. Тест — задача 2. +2. **`.gitignore` хоста прячет файл пакета** — у хостов шаблонный `.gitignore` (`lib/`, `build/`, `out`, `dist`, + `coverage`, `logs`, `*.spec`, `*.log`…): разложенный файл есть на диске, но в коммит не попадёт, и в чужом клоне + `--check` даст `20`. Раскладка (`--in-place`, обычный режим) завершается кодом 1 и называет игнорируемые файлы. Тест — + задача 3. +3. **Добавленный и удалённый файл копии**, а не только изменённый, — `20` с именем файла. Тест — задача 2. +4. **Запуск из `Makefile` хоста с `--host-dir .`** из worktree `.wt/<задача>/<хост>`: относительный путь, имя хоста по + `origin` (`arm-tabular-parser` → `tabular-parser`), проверка словаря имён — только у `arm-core` (у scan-gateway тоже + есть `api/rules/statement_lines.yaml`, его не проверять). Тест — задача 2. +5. **`VERSION` с ветки фичи `arm-statements`** (коммит есть в источнике, но не предок `--ref`) — `20` «чужая версия», + а не `10`. Тест — задача 2. + +--- + +## Структура файлов + +| Файл | Репозиторий | Ответственность | +|---|---|---| +| `scripts/statements_check.py` | docs | помощник: зависимости пакета против `requirements.txt` хоста; коды словаря имён против перечня | +| `scripts/sync-statements.sh` | docs | разбор аргументов, поиск источника и хоста, `--check`, раскладка (`--in-place`, `--dry-run`, обычный режим) | +| `scripts/test_sync_statements.sh` | docs | сценарии на синтетических репозиториях | +| `Makefile` | docs | `venv` (+ PyYAML), `test-sync-statements` (в `check`), `sync-statements`, `statements-check` | +| `dev/checks.md` | docs | таблица «Проверки этого репозитория» (ветка П-1); раздел «Копия общего кода» (задача 6) | +| `api/arm_statements/**`, `api/pyproject.toml`, `api/pytest.ini`, `api/requirements-dev.txt`, `Makefile`, `CLAUDE.md` | хосты | подключение копии (задачи 7–9) | + +--- + +### Task 1: Помощник `statements_check.py` и каркас теста + +**Files:** +- Create: `$D/scripts/statements_check.py`, `$D/scripts/test_sync_statements.sh` +- Modify: `$D/Makefile` (`venv`, `PY`, новая цель `test-sync-statements`) + +**Interfaces:** +- Produces (`scripts/statements_check.py`, только stdlib + `yaml`, Python ≥ 3.11): + - `normalize_name(name: str) -> str` — PEP 503: `re.sub(r"[-_.]+", "-", name).lower()`; + - `requirement_name(line: str) -> str | None` — имя из строки требования по `^[A-Za-z0-9][A-Za-z0-9._-]*` (экстры + `[crypto]`, версии, маркеры отбрасываются); пустая строка, комментарий, строка с `-` в начале (`-r`, `--index-url`) — + `None`; + - `missing_dependencies(pyproject_text: str, requirements_text: str) -> tuple[list[str], list[str]]` — (имена из + `[project].dependencies` пакета, которых нет среди имён `requirements.txt`; примечания «версии различаются: пакет + `PyYAML==6.0.3`, хост `PyYAML==6.0.4`»); нет `[project].dependencies` — пустой список; + - `codes_outside_orders(lines_doc: object, orders_doc: object) -> list[str]` — для каждой записи `editions[] = {id, + forms: {ФОРМА: [{code, name}]}}` словаря имён: код, которого нет в объединении вариантов `orders.editions[id][ФОРМА]` + (`full` ∪ `simplified`) перечня; сообщение `код 1120 формы BALANCE редакции fsbu4 нет в перечне копии пакета`; + редакции или формы нет в перечне — сообщение по каждому её коду; + - CLI `main(argv: list[str]) -> int`: `deps <pyproject.toml> <requirements.txt>` и `lines <statement_lines.yaml> + <statement_code_orders.yaml>`; печатает находки по строке, примечания — с префиксом `примечание: `; код `0` — нет + находок, `20` — есть, `1` — файл не читается или не той формы (сообщение об ошибке в stderr), `2` — неверные аргументы. +- Produces (`Makefile`): `PY` — без изменений смысла; `venv` ставит `jsonschema pyyaml`; цель + `test-sync-statements: STATEMENTS_PY=$(PY) scripts/test_sync_statements.sh`. Строки `PY` (комментарий), `venv` и + комментарий над ней — **дословно как в ветке `feat/116-unified-normalization`** (иначе конфликт при слиянии #117): + «`# Если окружение создано (`make venv`) — берём его интерпретатор, иначе системный python3 (в нём должны быть + jsonschema и PyYAML)`», «`# Зависимости скриптов проверки контракта — jsonschema и PyYAML (перечни кодов форм + contracts/statement-code-orders.yaml)`», «`uv pip install --python $(VENV)/bin/python jsonschema pyyaml`». +- Produces (`scripts/test_sync_statements.sh`): каркас как у `test_sync_web.sh` — `WORK=$(mktemp -d)` с `trap`, + `check <0|1> <описание>`, `put <путь> [текст]`, `commit_push <repo> <msg> <branch>`; интерпретатор — + `PY=${STATEMENTS_PY:-python3}`, в начале проверка `"$PY" -c 'import tomllib, yaml'` (нет — `exit 1` с подсказкой + `make venv`); раздел «помощник» в начале, следующие задачи дописывают свои разделы; в конце `ok: sync-statements.sh …`. + +- [ ] **Step 1: Write the failing test** — раздел «помощник» в `test_sync_statements.sh`: файлы в `$WORK/h/`. + + | Проверка | Вход | Ожидание | + |---|---|---| + | deps: есть | pyproject `[project]\ndependencies = ["PyYAML==6.0.3"]`; requirements `aiohttp==3.14.3\nPyYAML==6.0.3` | код 0, пустой вывод | + | deps: регистр и `_` | requirements `pyyaml==6.0.3` | код 0 | + | deps: нет | requirements `aiohttp==3.14.3\n# PyYAML==6.0.3\n-r other.txt` | код 20, в выводе `PyYAML` | + | deps: другая версия | requirements `PyYAML==6.0.4` | код 0, в выводе `примечание:` и `6.0.4` | + | deps: без `[project]` | pyproject `[tool.ruff]\nline-length = 120` | код 0 | + | deps: экстры хоста | pyproject `dependencies = ["PyJWT==2.8.0"]`, requirements `PyJWT[crypto]==2.8.0` | код 0 | + | lines: все коды есть | словарь: `66n` `BALANCE` `1110`, `1100`; `fsbu4` `BALANCE` `1110`; перечень: обе редакции `BALANCE: {full: ["1110", "1100"], simplified: ["1100"]}`, `INCOME: {full: ["2110"], simplified: ["2110"]}` | код 0 | + | lines: код вне перечня | в `fsbu4` `BALANCE` словаря добавлен `1120` | код 20, в выводе `1120` и `fsbu4` | + | lines: код только в `simplified` | перечень `fsbu4 BALANCE: {full: ["1110"], simplified: ["1100"]}`, словарь `fsbu4` `1100` | код 0 | + | битый YAML | словарь `editions: [` | код 1 | + | аргументы | без аргументов | код 2 | + + Синтетический перечень — в форме `contracts/statement-code-orders.yaml` (`version: 1`, `repeats`, `editions: + {66n: {BALANCE: {full, simplified}, INCOME: …}, fsbu4: …}`); словарь — в форме `arm-core/api/rules/statement_lines.yaml` + (`editions: - {id: 66n, forms: {BALANCE: [{code: "1110", name: "…"}]}}`). Запуск — `"$PY" "$HERE/statements_check.py" …`. + +- [ ] **Step 2: Run test to verify it fails** + + Run: `cd $D && make venv && make test-sync-statements` + Expected: FAIL — `statements_check.py` не найден (строки `FAIL:`), код 1. + +- [ ] **Step 3: Implement** `scripts/statements_check.py` по интерфейсу; `tomllib.loads`, `yaml.safe_load`; docstring + модуля — назначение и ссылка на спеку `statement-normalization`. Правка `Makefile` (П-1 ветки — `origin/main`: `.PHONY` + дополнить `test-sync-statements`; `check` пока не трогать). + +- [ ] **Step 4: Run test to verify it passes** + + Run: `cd $D && make test-sync-statements` + Expected: все строки `ok:`, код 0. + +- [ ] **Step 5: Commit** + +```bash +git -C "$D" add scripts/statements_check.py scripts/test_sync_statements.sh Makefile +git -C "$D" commit -m "feat(scripts): помощник statements_check.py — зависимости пакета и коды словаря имён против перечня (#<issue>)" +``` + +--- + +### Task 2: `sync-statements.sh --check` + +**Files:** +- Create: `$D/scripts/sync-statements.sh` +- Modify: `$D/scripts/test_sync_statements.sh` (раздел «--check») + +**Interfaces:** +- Consumes: CLI `statements_check.py deps|lines` (задача 1). +- Produces (`scripts/sync-statements.sh`): + - Аргументы: `--check`, `--in-place`, `--dry-run`, `--host <имя>`, `--host-dir <dir>`, `--ref <ref>` (источник, + по умолчанию `origin/main`), `--host-ref <ref>` (база worktree хоста, по умолчанию `origin/main`), `--source <dir>`, + `--docs <dir>`, `--core-dir <dir>` (по умолчанию `$WS/arm-core`), `--wt-root <dir>` (по умолчанию `$WS/.wt`), `-h`. + `--check` важнее `--dry-run` и `--in-place` при любом порядке (как у `sync-web.sh`); `--in-place` вместе с + `--dry-run` — код 2; ни `--host`, ни `--host-dir` — код 2; неизвестное имя `--host` — код 2. + - Шапка-комментарий в стиле `sync-web.sh`: назначение, точка синхронизации (`VERSION`), режимы, коды, `-h` печатает её. + - Имя хоста: `--host`; иначе по `git -C <host> remote get-url origin` — последний компонент без `.git`: + `arm-core` → `arm-core`, `arm-tabular-parser` → `tabular-parser`, `arm-scan-gateway` → `scan-gateway`; иначе имя + каталога; не из трёх — код 2. + - Интерпретатор помощника: `STATEMENTS_PY` → `$DOCS/.venv/bin/python` → `$HOST/api/.venv/bin/python` → `python3`; + первый, у которого `import tomllib, yaml` проходит; ни одного — код 2 с подсказкой `make -C docs venv`. + - `git -C "$SRC" fetch -q origin`; ошибка fetch — предупреждение «fetch не удался, сравниваю с локальным <ref>», + работа продолжается. `--ref` не разрешается в источнике — код 2. + - Функция `evaluate <copy_dir> <requirements_file> <lines_file|""> <host_name>` — печатает находки и выставляет + `EVAL_CODE` (0/10/20); используется задачами 3–4 для проверки дерева после раскладки и дерева `--host-ref`. + - Итоговая строка `--check` (её ищут тесты и `Makefile` хоста): + `statements-check <хост>: 0 — копия синхронна с arm-statements@<ref> (<v7>)`; + `statements-check <хост>: 10 — ПРЕДУПРЕЖДЕНИЕ: <причины через «; »>`; + `statements-check <хост>: 20 — расхождение, копию не принимать`; код возврата = число в строке. + +`evaluate` проверяет по порядку (печатает всё найденное): + +| # | Условие | Сообщение (суть) | Код | +|---|---|---|---| +| 1 | нет каталога `api/arm_statements/` | «копии нет: разложите `sync-statements.sh --in-place --host-dir <dir>`» | 20 (остальное не проверяется) | +| 2 | нет `VERSION`, не 40 hex, коммита нет в источнике | «VERSION … нет в истории arm-statements» (+ «сделайте fetch», если fetch не удался) | 20 (3–4 не проверяются) | +| 3 | коммит `VERSION` не предок `--ref` | «чужая версия: <v7> не из истории <ref>» | 20 | +| 4 | файлы копии ≠ `git archive <VERSION> arm_statements` (извлечь во временный каталог; `diff -rq` с исключениями `VERSION`, `__pycache__`, `*.pyc`, `.pytest_cache`, `.mypy_cache`, `.ruff_cache`, `.DS_Store`) | «прямая правка копии:» и по строке на файл — путь относительно `arm_statements/` (изменён / только в копии / только в источнике) | 20 | +| 5 | `statements_check.py deps <pyproject.toml коммита VERSION> <requirements_file>` = 20 | его вывод | 20 | +| 6 | хост `arm-core` и есть `api/rules/statement_lines.yaml`: `statements_check.py lines <словарь> <копия>/rules/statement_code_orders.yaml` = 20 | его вывод | 20 | +| 7 | нет `$DOCS/contracts/statement-code-orders.yaml` или он не равен байтово `<копия>/rules/statement_code_orders.yaml` | «перечень кодов в docs отличается от копии: contracts/statement-code-orders.yaml» (или «нет в docs») | 10 | +| 8 | `VERSION` ≠ `git rev-parse <ref>` | «копия отстала: <v7>, <ref> — <r7>», список `git log --format=' %h %s' <VERSION>..<ref>` | 10 | + + Код 1 помощника — код скрипта 1 с его сообщением. Режим `--check` читает рабочее дерево `--host-dir` (незакоммиченная + правка тоже `20`); `requirements_file` — `<host>/api/requirements.txt`. + +- [ ] **Step 1: Write the failing test** — раздел «--check» в `test_sync_statements.sh`. + + Подготовка: `arm-statements.git` (bare) + клон `$WORK/arm-statements`, ветка `main`: `pyproject.toml` + (`[project]`, `name = "arm-statements"`, `dependencies = ["PyYAML==6.0.3"]`), `README.md`, `arm_statements/__init__.py`, + `arm_statements/steps.py`, `arm_statements/rules/statement_code_orders.yaml` (синтетический перечень задачи 1), + `arm_statements/tests/__init__.py`, `arm_statements/tests/test_steps.py`; коммит S1, push. `$WORK/docs/contracts/ + statement-code-orders.yaml` — байтовая копия перечня S1 (не git). Хосты — bare + клон: `$WORK/arm-core` + (origin `arm-core.git`; `api/requirements.txt` с `PyYAML==6.0.3`; `api/rules/statement_lines.yaml` — словарь задачи 1), + `$WORK/tabular-parser` (origin `arm-tabular-parser.git`), `$WORK/scan-gateway` (origin `arm-scan-gateway.git`; + `api/rules/statement_lines.yaml` с кодом `9999` в `fsbu4`). В каждый хост копия кладётся вручную в тесте: + `git -C "$SRC" archive S1 arm_statements | tar -x -C <host>/api` и `VERSION` = полный хэш S1; коммит, push. + `COMMON=(--source "$WORK/arm-statements" --docs "$WORK/docs" --wt-root "$WORK/wt" --core-dir "$WORK/arm-core")`. + + | Проверка | Действие | Ожидание | + |---|---|---| + | синхронно | `--check --host-dir $WORK/arm-core` | 0, вывод содержит `statements-check arm-core: 0` | + | имя по origin, словарь не у scan-gateway | `--check --host-dir $WORK/scan-gateway` | 0, вывод содержит `scan-gateway`, нет `9999` | + | `--host-dir .` из каталога хоста | `(cd $WORK/tabular-parser && "$SYNC" --check --host-dir . "${COMMON[@]}")` | 0, `statements-check tabular-parser: 0` | + | кэши | в копии tabular-parser `__pycache__/steps.cpython-311.pyc`, `tests/.pytest_cache/x`, `.mypy_cache/x`, `.DS_Store` | 0 | + | изменён файл | дописать строку в `api/arm_statements/steps.py` arm-core (без коммита) | 20, в выводе `steps.py` и `прямая правка` | + | добавлен файл | новый `api/arm_statements/extra.py` (правку `steps.py` откатить) | 20, в выводе `extra.py` | + | удалён файл | удалить `api/arm_statements/tests/test_steps.py` | 20, в выводе `test_steps.py` | + | нет `VERSION` | удалить `VERSION` | 20 | + | неизвестный `VERSION` | `VERSION` = `0000…` (40 нулей) | 20 | + | чужая версия | в источнике ветка `feat/x` с коммитом F (правка `steps.py`), push; копия arm-core = дерево F, `VERSION` = F | 20, в выводе `чужая версия` | + | нет зависимости | arm-core: `api/requirements.txt` без PyYAML (копия = S1) | 20, в выводе `PyYAML` | + | код словаря вне перечня | arm-core: в `fsbu4` словаря `1120` | 20, в выводе `1120` и `fsbu4` | + | перечень в docs новее | `$WORK/docs/contracts/statement-code-orders.yaml` дописан `# новый код` | 10, в выводе `contracts/statement-code-orders.yaml`; затем вернуть файл | + | перечня нет в docs | `--docs $WORK/docs-empty` (пустой каталог) | 10 | + | отстала | в источнике коммит S2 (правка `README.md`), push | 10, в выводе `копия отстала` и тема S2 | + | правка + отставание | правка `steps.py` arm-core при S2 | 20 (не 10) | + | нет источника | `--source $WORK/nope` | 2 | + | нет хоста | `--host-dir $WORK/nope` | 2 | + | неизвестный аргумент | `--bogus` | 2 | + | нет `--host` / `--host-dir` | только `--check` | 2 | + | `--in-place --dry-run` | оба флага | 2 | + + Каждая проверка после себя возвращает хосты к копии S1 (`git -C <host> checkout -- . && git -C <host> clean -fdq`). + Перед «отстала» и после неё порядок проверок не меняется: проверки с S2 идут последними. + +- [ ] **Step 2: Run test to verify it fails** + + Run: `cd $D && make test-sync-statements` + Expected: FAIL — строки `FAIL:` раздела «--check» (скрипта нет). + +- [ ] **Step 3: Implement** разбор аргументов, поиск workspace/источника/хоста (как в `sync-web.sh`: `WS` по кандидатам + `$HERE/../../../..` и `$HERE/../..`, где рядом есть `arm-statements/.git`), выбор интерпретатора, `evaluate` и режим + `--check`. Режимы раскладки пока завершаются `die "режим не реализован" 1`. + +- [ ] **Step 4: Run test to verify it passes** + + Run: `cd $D && make test-sync-statements` + Expected: все строки `ok:`, код 0. + +- [ ] **Step 5: Commit** + +```bash +git -C "$D" add scripts/sync-statements.sh scripts/test_sync_statements.sh +git -C "$D" commit -m "feat(scripts): sync-statements.sh --check — свежесть копии arm_statements в хосте, коды 0/10/20/2 (#<issue>)" +``` + +--- + +### Task 3: Раскладка `--in-place` + +**Files:** +- Modify: `$D/scripts/sync-statements.sh`, `$D/scripts/test_sync_statements.sh` (раздел «--in-place») + +**Interfaces:** +- Consumes: `evaluate` (задача 2). +- Produces: функция `lay_copy <host_root> <commit>` — удаляет `api/arm_statements/` целиком, извлекает + `git -C "$SRC" archive <commit> arm_statements | tar -x -C <host_root>/api`, пишет `VERSION` (`<полный хэш>\n`); затем + две защиты, любая — код 1 со списком: + - игнорируемые файлы: `git -C <host_root> ls-files --others --ignored --exclude-standard -- api/arm_statements` после + исключения кэшей (`__pycache__`, `*.pyc`, `.pytest_cache`, `.mypy_cache`, `.ruff_cache`) непуст — «файлы копии + игнорируются .gitignore хоста, в коммит не попадут:»; + - содержимое, на котором остановится перенос в контур (`prepare-contour-snapshot.sh`, шаг 8): `grep -rIn + "/Users/\|/home/[a-z]\|git.aepif.ru\|uv venv\|uv pip"` по копии — «копия сломает снимок для контура:». +- Режим `--in-place` (хост — `--host` или `--host-dir`; рабочее дерево, без git-операций): + 1. если копия есть — `evaluate` против её `VERSION`; код 20 по условиям 1–4 (правка, чужая версия) — отказ `20`, ничего + не пишется; + 2. зависимости `--ref` (`pyproject.toml` коммита `--ref`) против `api/requirements.txt` хоста и (только `arm-core`) + словарь имён против перечня коммита `--ref` (`git show <ref>:arm_statements/rules/statement_code_orders.yaml`) — + находка — отказ `20`, ничего не пишется; + 3. копия уже на `--ref` и равна ему — «раскладывать нечего», переход к шагу 5; + 4. `lay_copy`; + 5. `evaluate` итога; печать итоговой строки как у `--check`; код возврата = `EVAL_CODE`. + Подсказка в конце: `закоммитьте api/arm_statements отдельным коммитом: git -C <host> add api/arm_statements`. + +- [ ] **Step 1: Write the failing test** — раздел «--in-place». Новый хост без копии: bare + `$WORK/fresh/arm-tabular-parser.git` + клон `$WORK/fresh/tabular-parser` (имя хоста выводится из origin — + `tabular-parser`), `api/requirements.txt` с `PyYAML==6.0.3`, коммит, push; ниже он — «fresh». + + | Проверка | Действие | Ожидание | + |---|---|---| + | первичная раскладка | хост без `api/arm_statements/`, `--in-place --host-dir <fresh> --ref origin/main` (S2) | 0; `VERSION` = полный хэш S2; `diff -r` копии (без `VERSION`) с `git archive S2 arm_statements` пуст; `git status` хоста не менялся вне `api/arm_statements` | + | повтор | ещё раз | 0, в выводе `раскладывать нечего` | + | обновление | копия arm-core на S1, `--in-place --host-dir $WORK/arm-core` | 0, `VERSION` = S2 | + | отказ при правке | в копии arm-core правка `steps.py`, `--in-place` | 20; `steps.py` с правкой на месте, `VERSION` не изменился | + | отказ без зависимости | `api/requirements.txt` fresh без PyYAML | 20; копия не тронута | + | `.gitignore` прячет файл | в источнике коммит S3: `arm_statements/tests/golden/case.json`; в fresh `.gitignore` строка `*.json`; `--in-place` | 1; в выводе `case.json` и `.gitignore` | + | содержимое для контура | в источнике коммит S4: строка `# /Users/someone` в `steps.py`; `--in-place` в fresh без `*.json` в `.gitignore` | 1; в выводе `/Users/` | + + Коммиты S3/S4 в источнике делаются на отдельной ветке `test-guards` и передаются через `--ref origin/test-guards`, чтобы + `origin/main` (S2) остался для задачи 4. + +- [ ] **Step 2: Run test to verify it fails** + + Run: `cd $D && make test-sync-statements` + Expected: FAIL — строки `FAIL:` раздела «--in-place». + +- [ ] **Step 3: Implement** `lay_copy` и режим `--in-place` по интерфейсу. + +- [ ] **Step 4: Run test to verify it passes** + + Run: `cd $D && make test-sync-statements` + Expected: все строки `ok:`, код 0. + +- [ ] **Step 5: Commit** + +```bash +git -C "$D" add scripts/sync-statements.sh scripts/test_sync_statements.sh +git -C "$D" commit -m "feat(scripts): sync-statements.sh --in-place — раскладка копии в рабочее дерево хоста с защитой от .gitignore и путей для контура (#<issue>)" +``` + +--- + +### Task 4: Обычный режим и `--dry-run`: worktree, issue, проверки хоста, PR + +**Files:** +- Modify: `$D/scripts/sync-statements.sh`, `$D/scripts/test_sync_statements.sh` (раздел «синхронизация») + +**Interfaces:** +- Consumes: `evaluate` (задача 2), `lay_copy` (задача 3). +- Produces — обычный режим (без `--check`/`--in-place`), по шагам `sync-web.sh`: + 1. `fetch` источника и хоста; `TARGET` = `git -C "$SRC" rev-parse <ref>`, `T7` = 7 символов. + 2. Состояние хоста на `--host-ref`: нет `<host-ref>:api/arm_statements` — код 2 «копии нет: подключение хоста — вручную, + `--in-place` в worktree задачи подключения (dev/checks.md)». Иначе — дерево `git archive <host-ref> api` во временный + каталог и `evaluate` по нему (`requirements` — оттуда же); `20` — отказ `20` с находками, worktree не создаётся. + 3. Зависимости и словарь имён против `TARGET` (как шаг 2 `--in-place`) — находка — отказ `20`. + 4. `VERSION` на `--host-ref` = `TARGET` — «копия уже на <T7>, раскладывать нечего», код 0 (предупреждение о перечне + кодов, если есть). + 5. «Сначала ядро»: для хоста ≠ `arm-core` — `VERSION` в `<core-dir>@origin/main`; его нет, или он ≠ `TARGET` и + `TARGET` не его предок — предупреждение `ядро (arm-core@origin/main) ещё не на <T7>: по порядку выкладки сначала + ядро` (не блокирует; строка уходит и в описание PR). + 6. Worktree `$WT_ROOT/statements-sync-<T7>/<хост>` на `tmp/statements-sync-<T7>` от `--host-ref`; существует worktree + или ветка — код 2; `touch .bounded`. + 7. Не `--dry-run`: issue (`tea issues create --login home --repo <Gitea-репозиторий> --title "arm_statements: копия + пакета arm-statements@<T7>" --description …` — версия копии было/стало, коммиты `<VERSION>..<TARGET>`, «bounded, + выполнена `docs/scripts/sync-statements.sh`»); ветка переименовывается в `feat/<issue>-statements-sync-<T7>`. + 8. `lay_copy "$WT" "$TARGET"`; `evaluate` по worktree — допустимы `0` и `10` только по перечню кодов (условие 7), иначе + код 1, worktree оставлен. + 9. `--dry-run`: печать «dry-run: issue, проверки, коммит и PR не выполнялись» и команды удаления worktree и ветки; код 0. + 10. Проверки хоста: `make -C "$WT" venv` и `make -C "$WT" check`; ошибка — хвост вывода (40 строк), worktree оставлен, + код 1. + 11. Коммит `chore(arm_statements): копия arm-statements@<T7> (#<issue>)`, тело — диапазон, коммиты источника, итог + `statements-check`, трейлер `Co-Authored-By: sync-statements.sh <noreply@aepif.ru>`; `push -u`; PR (`tea pulls + create … --head <ветка> --base <host-ref без origin/>`), тело: «Закрывает #<issue>», было/стало, коммиты, итог `make + check` и `statements-check` (предупреждение — дословно), предупреждение «сначала ядро» (если было), раздел + `## Rulings I made` (R-1 — копия не правится, перенос проревьюенного в arm-statements кода; R-2 — bounded без + OpenSpec change), строка `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. + 12. Итог: коммит, ветка, issue, PR, «исполнителю: ревью диапазона не нужно — код проревьюен в arm-statements; merge и + «закрывай» по команде человека». + +- [ ] **Step 1: Write the failing test** — раздел «синхронизация» (хосты arm-core и tabular-parser с копией S1, + `origin/main` источника = S2). + + | Проверка | Действие | Ожидание | + |---|---|---| + | dry-run создаёт worktree | `--dry-run --host-dir $WORK/arm-core` | 0; есть `$WORK/wt/statements-sync-<S2_7>/arm-core`; ветка `tmp/statements-sync-<S2_7>`; есть `.bounded`; `VERSION` = S2; `diff -r` копии с `git archive S2 arm_statements` пуст | + | dry-run не трогает хост | после него | `git -C $WORK/arm-core status --porcelain` пуст, `VERSION` хоста = S1 | + | повтор dry-run | ещё раз | ≠ 0, в выводе `уже существует`; затем worktree и ветка удаляются | + | сначала ядро | `--dry-run --host-dir $WORK/tabular-parser` (у arm-core `origin/main` копия S1) | 0, в выводе `сначала ядро`; worktree удалить | + | уже синхронно | копия arm-core на S2 закоммичена и запушена, `--dry-run` | 0, `раскладывать нечего`, worktree не создан | + | отказ при прямой правке на `origin/main` | tabular-parser: правка `api/arm_statements/steps.py` закоммичена и запушена, `--dry-run` | 20, в выводе `steps.py`, worktree не создан | + | хост без копии | fresh-хост без `api/arm_statements` на `origin/main`, `--dry-run` | 2, в выводе `--in-place` | + | `tea`: флаги после подкоманды | `grep -nE '(^|[^[:alnum:]_-])tea[[:space:]]+-' sync-statements.sh` | пусто | + +- [ ] **Step 2: Run test to verify it fails** + + Run: `cd $D && make test-sync-statements` + Expected: FAIL — строки `FAIL:` раздела «синхронизация». + +- [ ] **Step 3: Implement** обычный режим и `--dry-run` по интерфейсу (`tea` и `make` в тесте не вызываются — тест + гоняет только `--dry-run`, как `test_sync_web.sh`). + +- [ ] **Step 4: Run test to verify it passes** + + Run: `cd $D && make test-sync-statements` + Expected: все строки `ok:`, код 0. + +- [ ] **Step 5: Commit** + +```bash +git -C "$D" add scripts/sync-statements.sh scripts/test_sync_statements.sh +git -C "$D" commit -m "feat(scripts): sync-statements.sh — worktree хоста, issue, make check и PR; --dry-run; предупреждение «сначала ядро» (#<issue>)" +``` + +--- + +### Task 5: Цели `make` docs, `dev/checks.md`, PR docs + +**Files:** +- Modify: `$D/Makefile`, `$D/dev/checks.md` (только таблица раздела «Проверки этого репозитория (`docs`)») + +**Interfaces:** +- Produces (`Makefile`, `.PHONY` дополнен): + - `sync-statements: STATEMENTS_PY=$(PY) scripts/sync-statements.sh $(SYNC_ARGS)` — комментарий: «Копия пакета + arm_statements в хост одной командой (worktree, issue, PR); SYNC_ARGS="--host arm-core [--check|--dry-run|--in-place]"»; + - `statements-check` — `--check` по трём хостам рядом (`arm-core tabular-parser scan-gateway`): по строке итога на + хост, затем сводка; `0` и `10` — зелёные, любой другой код — цель завершается кодом 1 после проверки всех трёх. + Комментарий: «обзор свежести копий во всех хостах; в check не входит — читает соседние репозитории»; + - `check: validate-contracts validate-openspec test-snapshot test-sync-web test-sync-statements`. +- Produces (`dev/checks.md`): строка `make check` таблицы — «все проверки ниже и `test-snapshot`, `test-sync-web`, + `test-sync-statements`»; новые строки после неё: `make test-sync-statements` (сценарии `sync-statements.sh` на + синтетических репозиториях: коды `--check`, `--in-place`, `--dry-run`, отказ при прямой правке), `make sync-statements` + (`SYNC_ARGS` как выше), `make statements-check` (обзор по трём хостам). Строки `make venv` и `make validate-contracts` + **не трогать** (их меняет PR #117 — без конфликта). + +- [ ] **Step 1: Проверить, что `statements-check` docs пока красный** — до подключения хостов копий нет. + + Run: `cd $D && make statements-check` + Expected (после реализации Step 2): три строки `statements-check <хост>: 20 — …` с причиной `копии нет`, цель — код 2 + от `make`. До Step 2 — `No rule to make target`. + +- [ ] **Step 2: Implement** цели `Makefile` и строки таблицы `dev/checks.md`. + +- [ ] **Step 3: Run checks** + + Run: `cd $D && make check && make statements-check; echo rc=$?` + Expected: `make check` — все цели зелёные (в т. ч. `ok: sync-statements.sh …`); `statements-check` — три строки + «копии нет», `rc=2`. + +- [ ] **Step 4: Commit** + +```bash +git -C "$D" add Makefile dev/checks.md +git -C "$D" commit -m "feat(make): sync-statements, statements-check и test-sync-statements в check (#<issue>)" +``` + +- [ ] **Step 5: Финальное ревью ветки (opus), PR** — `git -C "$D" fetch && git -C "$D" rebase origin/main`, `make check`, + `tea pulls create --login home --repo RSHB/arm-ka-docs --head feat/<issue>-sync-statements --base main`; описание: + «Закрывает #<issue>», что делает скрипт, коды, итог `make check`, решения `P2-1…P2-21` в разделе `## Rulings I made`, + таблица находок с маркерами и статусами. + +--- + +### Task 6: Спека, `design.md` и раздел «Копия общего кода» (ветка `feat/116-unified-normalization`) + +**Files:** +- Modify: `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md` +- Modify: `…/unified-statement-normalization/design.md` (раздел `## Decisions made without discussion`) +- Modify: `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/dev/checks.md` (раздел «Копия общего кода + `arm_statements` и `sync-statements`», абзац под таблицей «Состав `make check-full`») +- Modify: `…/unified-statement-normalization/proposal.md` («Порядок выкладки», шаг 1) + +**Interfaces:** +- Consumes: номера issue и PR docs (задача 5). + +- [ ] **Step 1: Спека** — в требовании «`sync-statements` — копии пакета в потребителях» после предложения «Без `--check` + скрипт раскладывает…» добавить: «С `--in-place` скрипт SHALL раскладывать копию коммита `--ref` в рабочее дерево + указанного хоста (существующий worktree) без issue, коммита и PR — для подключения хоста и для PR фичи, которой нужна + новая версия пакета; при прямой правке копии или недостающей зависимости — отказ с кодом `20`, ничего не пишется.» и + сценарий: + + ``` + #### Scenario: Копия в PR фичи + - **WHEN** PR scan-gateway нужна версия пакета новее, чем в `main` хоста + - **THEN** `sync-statements.sh --in-place --host-dir .` в worktree фичи раскладывает копию и пишет `VERSION`, копия + коммитится отдельным коммитом этого PR; `--check` после раскладки — `0` + ``` + +- [ ] **Step 2: `dev/checks.md`** — в разделе «Копия общего кода…»: снять «**Пока не реализовано:** …», вместо него — + «Скрипт и цели docs — arm-ka-docs#<issue>; `statements-check` сервисов появляется с PR подключения копии в каждом + хосте (план 2, задачи 7–9) — до шага 3 выкладки»; в пункт «Скрипт» добавить `--in-place` (как в спеке), `--dry-run`, + обзор `make -C docs statements-check`; в пункт «Где копия» — что конфиг линтеров пакета в копию не кладётся, а хост + держит `arm_statements` в строгом блоке `mypy` и в `testpaths` (P2-1, P2-3). Абзац «`statements-check` появляется в + сервисе вместе с копией… (шаги 3 и 5 выкладки)» — заменить на «вместе с копией пакета (PR подключения хоста)». +- [ ] **Step 3: `proposal.md`** — шаг 1: «`arm-statements` — пакет с тестами; `sync-statements` в docs; копия пакета + подключается к хостам (линтеры, `testpaths`, `statements-check`) — код сервисов её ещё не вызывает». +- [ ] **Step 4: `design.md`** — решения `P2-1…P2-21` раздела ниже с датой 18.10.2026 (формат раздела — как у + решений P1-n: решение, обоснование, цена). +- [ ] **Step 5: Проверить** — `cd /Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs && openspec validate + unified-statement-normalization --strict` — `valid`. +- [ ] **Step 6: Commit и push** + +```bash +G=/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs +git -C "$G" add openspec/changes/unified-statement-normalization dev/checks.md +git -C "$G" commit -m "openspec(#116): sync-statements — режим --in-place, подключение копии к хостам, решения P2-n" +git -C "$G" pull --rebase && git -C "$G" push +``` + +--- + +### Task 7: arm-core — подключение копии `arm_statements` + +**Files (worktree `$W/arm-core`, ветка `feat/<issue>-arm-statements-copy`, issue «Подключение копии пакета +arm_statements (arm-ka-docs#116, план 2)»):** +- Create: `api/arm_statements/**` (скриптом, руками не править) +- Modify: `Makefile`, `api/pyproject.toml`, `api/pytest.ini`, `api/requirements-dev.txt`, `CLAUDE.md` + +**Interfaces:** +- Consumes: `../docs/scripts/sync-statements.sh` (задачи 2–4, из `$W/docs` = docs `origin/main`). +- Produces для планов 3 (часть Б) и далее: `import arm_statements` из `api/`; `mypy src scripts arm_statements`; тесты + пакета в `make check`; цель `statements-check` в `check-full`. + +Правки (точные значения): + +| Файл | Правка | +|---|---| +| `Makefile` | `lint`: `$(BIN)/mypy src scripts arm_statements`; новые `ARM_KA_DOCS ?= ../docs` и цель `statements-check` (рецепт ниже); `check-full: check statements-check dbml-check check-infra test-integration e2e docker-build web-check`; `.PHONY` + `statements-check` | +| `api/pyproject.toml` | `[tool.ruff.lint.isort] known-first-party = ["src", "scripts", "arm_statements"]`; в строгий блок `[[tool.mypy.overrides]]` `module = ["src.models.*", "src.domain.*", "arm_statements", "arm_statements.*"]` + комментарий «копия общего кода (sync-statements, не править) — в строгом режиме, как в репозитории arm-statements» | +| `api/pytest.ini` | `testpaths =` строки `tests` и `arm_statements/tests` | +| `api/requirements-dev.txt` | `ruff==0.16.10`, `mypy==2.4.0`, `types-PyYAML==6.0.12.20260906` вместо строк с `>=`; комментарий «ruff, mypy, types-PyYAML — те же версии, что в arm-statements (копия пакета проверяется конфигом хоста)» | +| `CLAUDE.md` | в «Правила репозитория»: «`api/arm_statements/` — копия пакета `arm-statements`, напрямую не править: обновление — `../docs/scripts/sync-statements.sh` (`--in-place` в PR фичи), свежесть — `make statements-check` (в `check-full`)» | + +Рецепт `statements-check` (одинаковый в трёх хостах): + +```make +# Свежесть копии api/arm_statements/ (../docs/dev/checks.md): 0 — синхронна; 10 — отстала или перечень кодов в docs +# новее (цель зелёная, предупреждение вписать в описание PR); 20 и 2 — красная. Читает ../docs и ../arm-statements, +# поэтому только в check-full, не в check +ARM_KA_DOCS ?= ../docs +statements-check: + @rc=0; $(ARM_KA_DOCS)/scripts/sync-statements.sh --check --host-dir . || rc=$$?; \ + if [ $$rc -eq 10 ]; then echo "statements-check: ПРЕДУПРЕЖДЕНИЕ — впишите в описание PR"; \ + elif [ $$rc -ne 0 ]; then exit $$rc; fi +``` + +- [ ] **Step 1: Цель без копии — красная.** Добавить в `Makefile` только `ARM_KA_DOCS`, `statements-check` и `.PHONY`. + Run: `make -C $W/arm-core statements-check; echo rc=$?` + Expected: `statements-check arm-core: 20 — …`, «копии нет», `rc=2` (код make). +- [ ] **Step 2: Разложить копию.** + Run: `cd $W/arm-core && ../docs/scripts/sync-statements.sh --in-place --host-dir .` + Expected: `statements-check arm-core: 0` (или `10` только с причиной «перечень кодов в docs…», пока docs PR #117 не слит); + `api/arm_statements/VERSION` = `git -C /Users/andrey.epifancev/projects/awp-ca/arm-statements rev-parse origin/main`. +- [ ] **Step 3: Правки таблицы выше** (кроме уже сделанного в Step 1). +- [ ] **Step 4: Проверки.** + Run: `make -C $W/arm-core venv check` + Expected: зелёный; `mypy src scripts arm_statements` без ошибок. + Run: `cd $W/arm-core/api && .venv/bin/pytest --collect-only -q arm_statements/tests | tail -1` и + `cd /Users/andrey.epifancev/projects/awp-ca/arm-statements && make venv >/dev/null && .venv/bin/pytest --collect-only -q | tail -1` + Expected: одинаковое число `tests collected`. + Run: `make -C $W/arm-core statements-check` + Expected: код 0, строка `0` или `10` (только перечень кодов). + Run: `make -C $W/arm-core docker-build && docker run --rm arm-core python -c "from arm_statements import default_rules, statements_version; default_rules(); print(statements_version())"` + Expected: печатается хэш из `api/arm_statements/VERSION` (словари `rules/*.yaml` в образе). +- [ ] **Step 5: `make check-full`** (`make up` при необходимости). Expected: зелёный; итог и предупреждение + `statements-check` (если было) — в описание PR. +- [ ] **Step 6: Commit (два коммита) и PR.** + +```bash +git -C "$W/arm-core" add api/arm_statements +git -C "$W/arm-core" commit -m "chore(arm_statements): копия пакета arm-statements@<v7> (#<issue>)" +git -C "$W/arm-core" add Makefile api/pyproject.toml api/pytest.ini api/requirements-dev.txt CLAUDE.md +git -C "$W/arm-core" commit -m "build: копия arm_statements в линтерах и тестах, statements-check в check-full (#<issue>)" +git -C "$W/arm-core" push -u origin feat/<issue>-arm-statements-copy +tea pulls create --login home --repo RSHB/arm-core --head feat/<issue>-arm-statements-copy --base main --title "Подключение копии пакета arm_statements (#<issue>)" --description "…" +``` + + Описание PR: что подключено, что код сервиса пакет ещё не вызывает (это план 3, часть Б), итог `check-full`, + `## Rulings I made` (P2-1…P2-4, P2-20), строка Claude Code. + +--- + +### Task 8: tabular-parser — подключение копии + +**Files (worktree `$W/tabular-parser`, issue в `RSHB/arm-tabular-parser`):** как в задаче 7. + +Шаги — задачи 7 с такими отличиями: + +| Файл | Правка | +|---|---| +| `Makefile` | `lint`: `$(BIN)/mypy src arm_statements`; `check-full: check statements-check test-integration docker-build` | +| `api/pyproject.toml` | `known-first-party = ["src", "arm_statements"]`; **новый** блок `[[tool.mypy.overrides]] module = ["arm_statements", "arm_statements.*"]` с флагами строгого блока arm-core (`disallow_any_generics`, `disallow_subclassing_any`, `disallow_untyped_calls`, `disallow_untyped_defs`, `disallow_incomplete_defs`, `disallow_untyped_decorators`, `warn_return_any`, `warn_unused_ignores`, `no_implicit_reexport`, `strict_equality`, `extra_checks` = `true`) и комментарием про `strict = true` в overrides mypy 2.x (как у arm-core) | +| `api/requirements-dev.txt` | `ruff==0.16.10`, `mypy==2.4.0`, **добавить** `types-PyYAML==6.0.12.20260906` | +| `api/pytest.ini`, `CLAUDE.md` | как у arm-core | + + Образ — `tabular-parser` (`docker run --rm tabular-parser python -c …`). `.dockerignore` не менять (исключает + `api/tests`, но не `api/arm_statements`). Словари `api/rules/statement_*.yaml` сервиса этим PR **не** удаляются — это + план 4. + +--- + +### Task 9: scan-gateway — подключение копии + +**Files (worktree `$W/scan-gateway`, issue в `RSHB/arm-scan-gateway`):** как в задаче 7, без `requirements-dev.txt` +(пины уже `ruff==0.16.10`, `mypy==2.4.0`, `types-PyYAML==6.0.12.20260906`). + +| Файл | Правка | +|---|---| +| `Makefile` | `lint`: `$(BIN)/mypy src arm_statements`; в `check-full` после `$(MAKE) check` — `$(MAKE) statements-check`; в `help` — строка `statements-check - свежесть копии api/arm_statements (sync-statements --check), входит в check-full`; `lint` в `help` — `mypy src arm_statements` | +| `api/pyproject.toml` | `known-first-party = ["src", "arm_statements"]`; в строгий блок `module` добавить `"arm_statements", "arm_statements.*"` | +| `api/pytest.ini`, `CLAUDE.md` | как у arm-core | + + Образ — `scan-gateway:dev`. `per-file-ignores` `"tests/**"` на копию не распространяется — так и должно быть (P1-3). + +--- + +## Что должны включить планы 3, 4, 5 + +Подготовку хостов делает этот план (задачи 7–9, P2-5); планы хостов стартуют от `main` с копией. Им остаётся: + +| | План 3 (arm-core, часть Б) | План 4 (tabular-parser) | План 5 (scan-gateway) | +|---|---|---|---| +| Старт | после слияния PR задачи 7 (у плана 3 это уже записано: «PR плана 2 в arm-core слит до части Б») | после слияния PR задачи 8 | после слияния PR задачи 9 | +| Нужна версия пакета новее, чем в `main` хоста | `../docs/scripts/sync-statements.sh --in-place --host-dir .` в worktree фичи; копия — отдельным коммитом `chore(arm_statements): копия пакета arm-statements@<v7>`; руками не править | то же | то же | +| `make check-full` перед PR | `statements-check` — `0` или `10`; `10` (отстала / перечень кодов) — в описание PR | то же | то же | +| `service.statements_version` | — (ядро читает `statements_version()` для `STATEMENTS_VERSION_MISMATCH`) | результат пишет `statements_version()` пакета | то же | +| Словари сервиса | `api/rules/statement_checks.yaml` удалить (копия §7), `statement_lines.yaml` остаётся словарём имён — `statements-check` сверяет его коды с перечнем копии | `api/rules/statement_checks.yaml`, `statement_headers.yaml`, `statement_periods.yaml` удалить (переехали в пакет, P1-13) | `api/rules/ocr_lookalikes.yaml`, `statement_labels.yaml`, `statement_lines.yaml` — по плану 5 (в пакет переезжают двойники и подписи) | +| Не делать | не добавлять `per-file-ignores`, `exclude` или ослабляющие overrides для `arm_statements`; не менять пины ruff/mypy без пакета; `api/conftest.py` с `autouse` не заводить; `api/arm_statements` не добавлять в `.dockerignore`; Dockerfile не менять | то же | то же | + +--- + +## Решения автора плана без обсуждения (P2-n) + +Переносятся в `design.md` задачей 6 (дата 18.10.2026). + +- **P2-1 (O-1 плана 1).** Файл конфига линтеров в копию не кладётся; «конфиг пакета подключён к хосту» — это: ruff хоста + уже совпадает с пакетом (`line-length = 120`, `py311`, `E F I UP B PL`), в isort `known-first-party` добавлен + `arm_statements`; в `mypy` хоста модули `arm_statements.*` входят в строгий блок (те же флаги, что `strict = true` + пакета). Цена: блок флагов повторён в трёх хостах; если хост когда-нибудь станет строже пакета, копия покраснеет в + хосте — чинить в `arm-statements`. +- **P2-2 (O-2).** Пины `ruff==0.16.10`, `mypy==2.4.0`, `types-PyYAML==6.0.12.20260906` во всех хостах (у + tabular-parser и arm-core были `>=`; в их окружениях уже стоят эти версии — пины ничего не переформатируют). `pytest` + не закрепляется: тесты пакета не зависят от его версии. Цена: обновление ruff/mypy — согласованно, сначала пакет. +- **P2-3 (O-3).** `testpaths = tests arm_statements/tests` в `pytest.ini` хостов; `conftest` хостов не меняется: у них + нет `api/conftest.py`, а `tests/conftest.py` действует только на `tests/`; `env`, `asyncio_mode`, `--strict-markers` + тестам пакета не мешают. Проверка — равное число собранных тестов пакета в хосте и в `arm-statements`. +- **P2-4 (O-4).** Dockerfile хостов не меняется: `COPY api/ ./` уже кладёт `api/arm_statements/rules/*.yaml`, + `.dockerignore` их не исключает; платформа уже возит `api/rules/*.yaml` и `api/contracts/` (сервисы читают их с диска + в контуре). Проверка — запуск `default_rules()` в собранном образе (задачи 7–9). +- **P2-5.** Подключение хостов — три bounded PR этого плана (задачи 7–9), а не части планов 3/4/5: правки одинаковые и + механические, план 3 уже ждёт «PR плана 2 в arm-core», первая раскладка проверяет скрипт на настоящих хостах, а планы + 4/5 стартуют от `main` с копией. Цена: ещё три PR и копия в `main` хостов до того, как её вызывает код сервиса. +- **P2-6.** Режим `--in-place` (раскладка в существующий worktree без issue, коммита и PR) — для подключения хоста и для + PR фичи, которой нужна новая версия пакета; спека дополнена (задача 6). Цена: второй путь раскладки, мимо отдельного PR + синхронизации. +- **P2-7.** Скрипт — bash по образцу `sync-web.sh` (spec называет `sync-statements.sh`), разбор TOML/YAML — помощник + `statements_check.py` (Python 3.11 + PyYAML); интерпретатор — `STATEMENTS_PY` → `docs/.venv` → `api/.venv` хоста → + `python3`; подходящего нет — код `2`. Цена: `make venv` docs ставит PyYAML (как и в PR #117). +- **P2-8.** В `--check` отсутствие копии — `20` («копии нет»): хост, в котором вызывают проверку, обязан держать копию. + В обычном режиме хост без копии — `2` с указанием на `--in-place`: подключение хоста — ручная задача с правкой конфигов. +- **P2-9.** Нет `contracts/statement-code-orders.yaml` в docs — `10` (docs старше пакета; так до слияния PR #117). +- **P2-10.** Зависимости сверяются по имени (PEP 503) с `api/requirements.txt` хоста (его ставит образ); разные версии — + примечание, код не меняют. Цена: несовпадение версии PyYAML ловят только тесты пакета в хосте. +- **P2-11.** «Копия отстала» — буквально по спеке: `VERSION` ≠ `--ref`, даже если новые коммиты источника не трогают + `arm_statements/` (README, Makefile). Цена: лишние предупреждения `10` после таких коммитов (см. O-1). +- **P2-12.** Ошибка `fetch` в `--check` не фатальна (предупреждение, сравнение с локальными ссылками): проверка перед PR + не должна краснеть без сети, если копия на месте. +- **P2-13.** Сравнение — по файлам рабочего дерева (`diff -rq`), без `VERSION` и кэшей (`__pycache__`, `*.pyc`, + `.pytest_cache`, `.mypy_cache`, `.ruff_cache`, `.DS_Store`); биты режима не сравниваются. Незакоммиченная правка — тоже `20`. +- **P2-14.** Обычный режим перед коммитом гоняет `make venv` и `make check` хоста в новом worktree (как `sync-web.sh` — + проверки фронта). Цена: минута на окружение. +- **P2-15.** «Сначала ядро» при синхронизации сервиса — предупреждение (в выводе и в описании PR), не отказ: порядок + важен для смены формата результата, а не для каждой правки пакета. +- **P2-16.** `make -C docs statements-check` — обзор по трём хостам рядом (`0`/`10` — зелёные); в `make check` docs не + входит: читает соседние репозитории. +- **P2-17.** Имя хоста — `--host` или последний компонент `origin` (`arm-core`, `arm-tabular-parser`, + `arm-scan-gateway`), иначе имя каталога; сверка словаря имён — только у `arm-core`. +- **P2-18.** Раскладка отказывает (код 1), если файлы копии игнорируются `.gitignore` хоста или содержат то, на чём + остановится снимок для контура (`/Users/`, `/home/…`, `git.aepif.ru`, `uv venv`, `uv pip`). +- **P2-19.** Строки `make venv` docs — дословно как в ветке `feat/116-unified-normalization`, строки таблицы + `dev/checks.md`, которые меняет PR #117, не трогаются: два PR сливаются в любом порядке без конфликта. +- **P2-20.** В `CLAUDE.md` каждого хоста — правило «копию не править, обновлять скриптом»: агенты читают его первым. + Шаблоны `docs/repo-templates/` не меняются (разошлись с репозиториями давно). +- **P2-21.** Ветка docs плана — от `origin/main` (шаг 1 выкладки раньше PR #117); спека, `design.md` и раздел «Копия» + `dev/checks.md` правятся в `feat/116-unified-normalization` (задача 6). + +## Открытые вопросы (O-n) + +- **O-1.** P2-11: считать ли копию синхронной, если коммиты после `VERSION` не меняют `arm_statements/` и + `[project].dependencies`? Спека говорит «это коммит `--ref`»; смягчение уберёт шум, но поменяет требование. +- **O-2.** Есть ли в Nexus банка `ruff==0.16.10`, `mypy==2.4.0`, `types-PyYAML==6.0.12.20260906` (цель `venv` снимка + контура ставит `requirements-dev.txt` из Nexus)? У scan-gateway эти пины уже есть — вероятно, да; подтвердить при + следующем переносе. +- **O-3.** `prepare-contour-snapshot.sh` проверяет отставание `arm-core/web` (`sync-web --check`), но не свежесть копии + `arm_statements`. Добавить шаг `sync-statements --check` в подготовку снимка (предупреждение в `SNAPSHOT.md`) — по + решению владельца; в этом плане нет. +- **O-4.** `CLAUDE.md` workspace (таблица репозиториев) и `context/00-workspace-layout.md` не знают про `arm-statements/`; + правит план 1 (П-2) или отдельная правка docs — решить контроллеру плана 1. diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md new file mode 100644 index 0000000..a102c93 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md @@ -0,0 +1,1371 @@ +# План 3 — arm-core: хранение v5.19, правка формы, приём контракта 2.0. План реализации + +> **Для исполнителей-агентов:** обязательный навык — superpowers:subagent-driven-development (рекомендуется: в каждой +> части больше 5 задач) или superpowers:executing-plans. Шаги размечены чекбоксами (`- [ ]`). Контроллер SDD — фоновый +> сабагент на модели sonnet; исполнители — по SDD Model Selection (`~/.claude/CLAUDE.md`, «Исполнение»). + +**Goal:** ядро хранит формы ББ/ОФР по DBML v5.19 как пришли (строки без кода, повторы кодов, `needs_review`, +`document_issue`), читает их «по коду» через один агрегат, даёт аналитику разобрать строки и подтвердить ячейки и коды +через `PATCH …/statement`, принимает результаты `1.x` и `2.0` и пересчитывает форму после правки общим кодом +`arm_statements`. + +**Architecture:** две части — два PR в RSHB/arm-core. **Часть А** не зависит от пакета: схема v5.19 (одна миграция +`0007_dbml_v5_19`), скрипт очистки форм, `crm_id` ГСК/ГСЗ, модели контракта 2.0 и приём конвертов, словарь имён с +синонимами, агрегат формы по коду, новые поля API, порядок 409, `PATCH` (`lines`, `columns`, `values[].confirmed`, +`header.organization_name`), маппер `1.x` с полями v5.19 и чистые функции условия `RECOGNIZED`. До части Б §7 по-прежнему +считает ядро на лету своей копией словаря (`api/rules/statement_checks.yaml`), но уже по агрегату. **Часть Б** стартует, +когда копия пакета лежит в `api/arm_statements/` (план 2): адаптер пакета за протоколом `StatementsEngine`, маппер `2.0`, +§7 при приёме `1.x`, пересчёт после правки точкой входа пакета, чтение §7 только из `document_issue`, удаление копии +словаря §7 ядра. + +**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2 (async) + Alembic, PostgreSQL 16, pydantic v2, pytest-asyncio, ruff, +mypy; `tea` (Gitea), OpenSpec, codegraph. + +**Spec:** `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/` +(ветка `feat/116-unified-normalization` репозитория arm-ka-docs): главное — `specs/arm-core/spec.md`; ещё +`specs/recognition-contract/spec.md` (окно перехода, конверт, ack), `specs/statement-normalization/spec.md` («Шаг 8 — +статус документа и конверта», «Точка входа пакета для пересчёта по сохранённым данным»), `proposal.md` («Порядок +выкладки», шаг 3); модель — `model/data-model-v5.19.dbml` и `model/entities-v5.19.md` (против v5.18); контракты — +`contracts/*.schema.json` (v2.0), `contracts/*.v1.schema.json`, `contracts/examples/`, `contracts/examples/v1/`; правила — +`context/05-domain-rules.md` §7, §9.3, §9.5 пп. 2–4, §11.9; гайд фронта — `dev/frontend-api-guide.md` §5.6.1. Решения — +`design.md` (Р-1…Р-30, S-1…S-74; для ядра важны Р-4, Р-8, Р-10, Р-16, Р-18…Р-20, Р-23…Р-25, Р-27, Р-28, Р-30). + +--- + +## Место в работе и порядок + +| План | Что | Связь с этим планом | +|---|---|---| +| 0 | тест идентичности копий контракта по MAJOR (arm-core, tabular-parser, scan-gateway) | слит в arm-core **до** задачи A5 | +| 1 | пакет `arm_statements` (новый репозиторий) | ядро вызывает только имена раздела «Публичный API пакета» плана 1 (сверено, раздел «Что ядро вызывает» ниже) | +| 2 | `sync-statements`: копия пакета в `api/arm_statements/`, `statements-check` в `make check-full` | PR плана 2 в arm-core слит **до** части Б | +| docs PR #117 | контракт v2.0, DBML v5.19, правила | слит **до** слияния PR части А (dbml-check, тест идентичности против `docs` main) | +| 6 | arm-web: экран по новым полям, затем `sync-web` в `arm-core/web` | выкладывается вместе с ядром: `FileOut.job.warnings` меняет тип | + +**Выкладка на контур (шаг 3 `proposal.md`) — только А и Б вместе**, после очистки форм `purge_statements.py` и вместе с +фронтом плана 6. PR части А сливается в `main`, но снимок контура с одной частью А не делается: до части Б ядро не +принимает `2.0` и не пишет `document_issue` §7 (O-9). + +## Разбивка на PR + +| | Часть А | Часть Б | +|---|---|---| +| Issue (RSHB/arm-core) | «Единая нормализация ББ/ОФР, ядро — часть А: DBML v5.19, очистка форм, агрегат по коду, правка формы (arm-ka-docs#116)» | «Единая нормализация ББ/ОФР, ядро — часть Б: приём контракта 2.0 и пересчёт общим кодом (arm-ka-docs#116)» | +| Ветка | `feat/<issue-А>-normalization-core-a` | `feat/<issue-Б>-normalization-core-b` | +| Зеркальный workspace | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-a/` | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-b/` | +| Worktree arm-core | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-a/arm-core` | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-b/arm-core` | +| Change OpenSpec arm-core | `openspec/changes/unified-normalization-core-a/` | `openspec/changes/unified-normalization-core-b/` | +| Задачи | A0–A13 (14) | B0–B6 (7) | +| Старт | сразу (A5 — после плана 0) | после слияния PR части А и PR плана 2 в arm-core | + +Зеркальный workspace — как в плане 0 (`context/00-workspace-layout.md`, «Worktree вместо веток»): в каталоге части +лежат worktree `arm-core/` (ветка фичи), **отсоединённый** worktree arm-ka-docs `docs/` (до слияния #117 — на +`origin/feat/116-unified-normalization`, после — на `origin/main`; в нём не коммитится) и симлинки `fixtures`, +`reference-old-arm`, `CLAUDE.md`, `.claude` на основной workspace. Так `../docs` из `arm-core/api` и `DBML ?= +../../docs/model/…` указывают на docs с v5.19 без переменных окружения. + +Порядок создания (`~/.claude/CLAUDE.md`, «Роли инструментов»), на примере части А: + +```bash +WS=/Users/andrey.epifancev/projects/awp-ca +mkdir -p $WS/.wt/normalization-core-a +git -C $WS/arm-core fetch origin +git -C $WS/arm-core worktree add -b tmp/normalization-core-a $WS/.wt/normalization-core-a/arm-core origin/main +git -C $WS/docs fetch origin +git -C $WS/docs worktree add --detach $WS/.wt/normalization-core-a/docs origin/feat/116-unified-normalization +for l in fixtures reference-old-arm CLAUDE.md .claude; do ln -s $WS/$l $WS/.wt/normalization-core-a/$l; done +tea issues create --login home --repo RSHB/arm-core --title "<заголовок из таблицы>" --description "<цель, ссылка на план 3>" +git -C $WS/.wt/normalization-core-a/arm-core branch -m feat/<issue-А>-normalization-core-a +cd $WS/.wt/normalization-core-a/arm-core && codegraph init && make venv +``` + +Все вызовы `codegraph_explore` — с `projectPath` = абсолютный путь worktree части. PR — `tea pulls create --login home +--repo RSHB/arm-core`, со ссылкой на issue и на arm-ka-docs#116; в описание — вывод `make check-full`, таблица находок и +«Rulings I made». Слияние — только по команде человека. + +## Global Constraints + +- Источник истины по схеме — `model/data-model-v5.19.dbml`; `src.models.DBML_VERSION = "5.19"`; в `Makefile` + `DBML ?= ../../docs/model/data-model-v5.19.dbml`; в `arm-core/CLAUDE.md` «Правила репозитория» — v5.19 и миграция + `0007_dbml_v5_19`. +- Одна ревизия Alembic `0007_dbml_v5_19` (`down_revision = "0006_dbml_v5_18"`); только добавляет колонки, таблицу, enum'ы, + индексы и заполняет значения по умолчанию; §7 не считает; `arm_statements` не импортирует; схема — `from + src.models.base import SCHEMA`, не литерал. +- Строка, созданная правкой: `row_index IS NULL` ⇔ `origin = template`. Признак `line_code_original_value IS NULL` для + `origin` и для «строка не из источника» нигде не используется. Маппер `1.x` и `2.0` всегда пишет `row_index`. +- `line_code_original_value` и `header_source`, `*_original_value` правками не меняются. +- Строки `detail`, `unreadable`, `missing` не входят в агрегат по коду, в §7, в межформенную проверку. +- Деньги — `Decimal` / `numeric(18,2)`, в JSON — строки; `float` в домене нет. +- Порядок блокировок: задание → файл → документы → advisory ГК → версии (по `id`); правка формы — «файл → документ», шапка + перечитывается под блокировкой с `refresh=True`. +- Ошибки — `DomainError`: 404 `NOT_FOUND`, 409 `ConflictError`, 422 `DomainValidationError`, тело `{"detail": {"code", + "message", …}}`; 422 валидации тела pydantic — массив. Коды этого плана: 409 `LINE_CODE_TAKEN` (`line_id`, + `line_code`), `LINES_UNRESOLVED` (`lines[] {line_id, row_index, line_code_text}`), `DUPLICATE_FORM` (`document_id`), + `CRM_ID_EXISTS`; 422 `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE` (все четыре — с + `line_id`, `line_code` элемента `lines[]`, P3-24), `UNKNOWN_COLUMN`, `UNKNOWN_CELL`. Каталог + кодов в `arm-core/README.md` («Ошибки API») дополняется в той задаче, что вводит код. +- Один `PATCH …/statement` — один пересчёт и одна запись `audit_event` `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`, + `object_type = "document"`); запрос без изменений — 200 без аудита. Аудит — только через `services/audit.py`. +- Коды предупреждений и записей `document_issue` — дословно: `RELATION_FAILED` (`relation`, `error`), `RELATION_SOFT` + (`soft`, `info`), `CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT`, + `STATEMENTS_VERSION_MISMATCH`, `MAPPING_WARNING`, `LEGACY_WARNING` (все — `info`, источник `core`); снимаемые правкой + предупреждения сервиса — `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, `REPORT_DATE_UNCERTAIN`, `NO_ORGANIZATION_NO_INN`, + `COLUMN_WITHOUT_DATE`, `COLUMN_DATE_UNCERTAIN`; уровень файла `error` — по полю `severity`, не по списку кодов. +- `import arm_statements` — только в `api/src/services/statements_engine.py` (часть Б); копию `api/arm_statements/` не + править (её раскладывает `sync-statements`, план 2). +- Копии контракта в `api/contracts/` побайтно равны docs: основные схемы v2.0 (у запроса — v1.0), `*.v1.schema.json`, + `examples/*.json` (v2.0) и `examples/v1/*.json`; тест идентичности — правило плана 0. +- `make check` (без Docker) зелёный после каждой задачи; перед PR — `make check-full` (нужен Docker: `make up` из + `../docs/dev/`), его вывод — в описание PR. +- Язык кода-комментариев, docstring, коммитов, issue, PR — русский; идентификаторы — английские, как в DBML. Docstring + нового алгоритма называет § правил (`05-domain-rules.md`) или требование спеки `arm-core`. +- Коммиты заканчиваются строками `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>` и `Claude-Session: + https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W`; описание PR — строкой `🤖 Generated with [Claude + Code](https://claude.com/claude-code)`. +- Находки и решения — с маркерами (`T<n>.<k>`, `C-n`/`I-n`/`M-n`, `R-n`, `O-n`, `H-n`); решения этого плана — `P3-n` + (ниже); исполнитель переносит их в `design.md` своего change'а arm-core (раздел «Decisions made without discussion»). + +## Review Focus + +1. **Две формы одного типа и периода в одном файле** (лист-продолжение, двойная печать) — оба документа хранятся с + разными `file_ordinal`, ни один не `RECOGNIZED` и не автоподтверждён (Р-18); подтверждение второго после первого — + 409 `DUPLICATE_FORM`; формы разных периодов — каждая по своим условиям; автозамена и пара межформенной проверки не + падают на двух документах с одним ключом. Тесты — A9 (маппер `1.x`), A11 (409), B4 (маппер `2.0`). +2. **Повторы кода в агрегате** — суммы строк `ok` с одним кодом складываются по колонке (`100 + 649 = 749`), пустые не + превращаются в 0 (`null`, если непустых нет), межформенная проверка берёт сумму (`500 + 300 = 800`), `parent_code` + подстроки и шаблонная достройка видят код один раз. Тесты — A7. +3. **Строка без кода при назначении кода** — строка `unreadable` с суммами становится `ok`, `row_index` и + `line_code_original_value = null` сохраняются (`origin = source`), её значения входят в агрегат и §7, родители всех + строк `detail` пересчитаны; тот же код у другой строки источника — законный повтор, без 409. Тесты — A13. +4. **Шаблонная строка с суммами** (`row_index = null`) под назначаемым кодом — 409 `LINE_CODE_TAKEN`, и **ничего** из + запроса не применено (ни другие элементы `lines`, ни `values`, ни шапка); пустая шаблонная строка удаляется, её `id` + уходит из `document_issue.line_ids`. Тесты — A13. +5. **Старые задания со строковыми `warnings`** — `metrics.warnings` из строк, смесь строк и объектов, не список, объект + без обязательных полей: `JobBrief.warnings` отдаёт структуры, строки — как `LEGACY_WARNING`, мусор пропускается, данные + в БД не переписываются; условие `RECOGNIZED` не считает строки предупреждениями `error`. Тесты — A5, A10. + +--- + +## Карта файлов + +**Часть А** + +| Файл | Ответственность | +|---|---| +| `api/src/models/statements.py` | колонки v5.19 у `financial_statement_*`, новая модель `DocumentIssue` | +| `api/src/models/files.py` | `Document.file_ordinal`, частичный уникальный индекс типа, `RecognitionJob.statements_version` | +| `api/src/models/directories.py` | `RelatedGroup.crm_id` (unique) | +| `api/src/models/enums.py`, `api/src/models/__init__.py` | enum'ы v5.19 в `ENUMS`, `DBML_VERSION = "5.19"`, экспорт `DocumentIssue` | +| `api/alembic/versions/0007_dbml_v5_19.py` | миграция (upgrade с заполнением, downgrade с отказами и журналом) | +| `api/scripts/purge_statements.py` | очистка форм перед выкладкой | +| `api/src/contract/result_v2.py`, `api/src/contract/message.py`, `api/src/contract/versions.py` | модели контракта 2.0, разбор по MAJOR | +| `api/src/domain/statement_lines.py` | словарь имён: несколько имён на код | +| `api/src/domain/statement_names.py` | совпадение наименований (§11.9) | +| `api/src/domain/statement_aggregate.py` | агрегат формы по коду | +| `api/src/domain/statement_layout.py` | порядок строк ответа, ближайший родитель `detail` (временный, до Б) | +| `api/src/domain/statement_issues.py` | черновик записи `document_issue`, правила снятия предупреждений правкой | +| `api/src/domain/statement_status.py` | условие `RECOGNIZED` (2.0, Р-18/Р-19), статус ОСВ (Р-28), проверка наименований | +| `api/src/domain/statement_mapping.py` | черновики формы `1.x` с полями v5.19 | +| `api/src/repositories/statements.py` | вставка v5.19, агрегат, `document_issue`, удаление строк | +| `api/src/services/result_mapping.py` | маппер `1.x`: `file_ordinal`, Р-10, `MAPPING_WARNING` | +| `api/src/services/recognition_exchange.py` | приём ack и конвертов `1.x`/`2.0`, `metrics.warnings` структурами | +| `api/src/services/documents.py` | `JobBrief.warnings`, `DocumentOut.issues`, порядок 409 | +| `api/src/services/statements.py`, `api/src/services/statement_line_edits.py` | `PATCH`, блокеры подтверждения, пересчёт | +| `api/src/services/related_groups.py`, `api/src/common/constraint_conflicts.py` | `crm_id` ГСК/ГСЗ | +| `api/src/schemas/statements.py`, `files.py`, `documents.py`, `directories.py` | DTO | + +**Часть Б** + +| Файл | Ответственность | +|---|---| +| `api/src/services/statements_engine.py` | протокол `StatementsEngine` и адаптер к копии `arm_statements` (единственный импорт пакета) | +| `api/src/domain/stored_form.py` | сборка входа точки пересчёта из строк БД (чистая функция) | +| `api/src/services/statement_recompute.py` | пересчёт после правки и при приёме `1.x`, запись результатов | +| `api/src/domain/statement_mapping_v2.py` | черновики формы и ОСВ из результата `2.0` | +| `api/src/services/result_mapping.py` | выбор маппера по MAJOR, маппер `2.0` | +| удаляются: `api/rules/statement_checks.yaml`, §7-часть `api/src/domain/statement_checks.py` | копия словаря §7 ядра | + +--- + +## Что ядро вызывает у пакета (часть Б) + +Источник истины по именам и типам — раздел «Публичный API пакета» плана 1 (`2026-10-18-plan-1-arm-statements.md`); +здесь они не копируются. Ядро импортирует имена верхнего уровня `arm_statements` только в +`api/src/services/statements_engine.py` и `api/src/domain/stored_form.py` (сборка входа пересчёта); остальной код +работает с протоколом ниже. + +```python +# api/src/services/statements_engine.py — единственная точка вызова функций arm_statements +from arm_statements import (DocStatus, DocumentType, Edition, FormVariant, PeriodKind, RecomputeResult, Rules, + StoredForm, choose_edition, default_rules, document_status, recompute, resolve_relative, + status_input_from_contract, statements_version) + +class StatementsEngine(Protocol): # тонкая обёртка: подмена в модульных тестах (FakeEngine), кэш правил на процесс + @property + def version(self) -> str | None: ... # statements_version(); нет файла VERSION — None + def recompute(self, form: StoredForm) -> RecomputeResult: ... # recompute(form, rules) + def contract_document_status(self, document: Mapping[str, Any]) -> DocStatus: ... + # document_status(status_input_from_contract(document)) — statementDocument результата 2.0 как dict + def edition(self, document_type: DocumentType, codes: Sequence[tuple[str, bool]], + effective_date: datetime.date | None, form_variant: FormVariant | None) -> Edition | None: ... + # choose_edition(document_type, codes, effective_date, rules, variant=form_variant) + def code_order(self, edition: Edition, document_type: DocumentType, form_variant: FormVariant) -> tuple[str, ...]: ... + # rules.code_orders.sequence(edition, document_type, form_variant); DISCLOSURE → () + def editions(self) -> tuple[Edition, ...]: ... # rules.code_orders.editions — (RAS_66N, FSBU4) + def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None: ... + # ABSOLUTE / NONE → None + def tolerance(self) -> Decimal: ... # rules.checks.tolerance — Decimal("1"), для межформенной проверки ядра + +def default_engine() -> StatementsEngine: ... # ArmStatementsEngine(default_rules()), кэш на процесс +``` + +| Вызов ядра | Имя плана 1 | Где используется | +|---|---|---| +| `engine.recompute(form)` | `recompute(form: StoredForm, rules) -> RecomputeResult` | B2 (после `PATCH`), B3 (приём `1.x`) | +| `engine.contract_document_status(doc)` | `document_status(status_input_from_contract(doc))` | B4 (страховка статуса результата `2.0`) | +| `engine.edition(...)` | `choose_edition(document_type, codes, effective, rules, *, variant)` | B4 (редакция формы `2.0` для `CODE_NOT_IN_EDITION`) | +| `engine.code_order(...)` | `default_rules().code_orders.sequence(edition, form, variant) -> tuple[str, ...]` | B4, B6 | +| `engine.editions()` | `default_rules().code_orders.editions -> tuple[Edition, ...]` | B6 | +| `engine.resolve_relative(...)` | `resolve_relative(kind, effective) -> date \| None` | B2 (правка `header.report_date`) | +| `engine.tolerance()` | `default_rules().checks.tolerance -> Decimal` | B6 | +| `engine.version` | `statements_version()` | B2 (`STATEMENTS_VERSION_MISMATCH`) | +| `StoredForm`, `StoredColumn`, `StoredLine`, `StoredValue`, `StoredWarning` | одноимённые dataclass-ы пакета | B1 (`build_stored_form`) | +| `FailedRelation`, `StatementChecks` | из `RecomputeResult.checks` | B2 (`relations_to_issues`) | + +`to_contract` / `warning_to_contract` ядру не нужны: ядро читает контракт, а не пишет его. `StatusInput` ядро не собирает +— вход статуса из документа результата строит пакет (`status_input_from_contract`), поэтому страховка статуса считает +ровно то, что посчитал сервис (план 1, P1-27). + +Соглашения на границе ядра и пакета (все — в `stored_form.py` / адаптере, остальной код ядра их не видит): + +- Перечисления пакета — `StrEnum` (подкласс `str`, значения — строки контракта и БД): строка БД превращается в + `LineStatus(...)`, `DocumentType(...)`, `PeriodKind(...)`, `Severity(...)` при сборке `StoredForm`; `period_kind = NULL` + в БД → `PeriodKind.NONE`. Обратно значения пишутся в БД как `str(...)` / `.value`. +- Уверенности пакета — `float | None` (0–100), в БД — `Numeric(5,2)`: при сборке `float(confidence)`, `None` остаётся `None`. + `StoredValue.is_recognized` — `bool`: `NULL` в БД читается как `False`. +- `StoredColumn.column_index: int | None` (колонка без номера столбца допустима); ключи строки и колонки — `str(uuid)`. +- `RecomputeResult.edition` и `engine.edition(...)` — `Edition | None`; `None` при нераспознанной редакции (нет даты и + ничья): проверки по редакции (`CODE_NOT_IN_EDITION`) тогда не пишутся. Пары `codes` для `engine.edition` — строки `ok` + результата по `row_index`: `(line_code, line_code_confidence == 100)` (у результата `2.0` кодов, правленых аналитиком, + нет); пересчёт после правки берёт ту же функцию внутри `recompute` — редакция приёма и пересчёта совпадает. +- `FailedRelation` несёт `column_index: int | None` и `column_key: str`; при пересчёте из БД `column_key` — это + `StoredColumn.key`, то есть `str(column_id)`: колонка находится по ключу без обратного сопоставления по индексу. + `expected` и `actual` — `Decimal | None` (`None` — цель или все члены пусты, план 1 P1-21); `document_issue.expected` / + `actual` — `numeric(18,2)` без `NOT NULL`, `IssueDraft.expected` / `actual` — `Decimal | None`. + +Ключи хоста — `str(uuid)` строки и колонки (`key`, `column_key`, `parent_key`). + +--- + +## Решения плана без обсуждения (`P3-n`) + +| Маркер | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| P3-1 | Миграция пишет `header_source` с ключами `inn`, `report_date`, `units`, `organization_name`: `text` — из `inn_original_value`, `report_date_original_value`, `units_original_value`, `organization_name`; `confidence` — `inn_confidence` у `inn`, у остальных `null` | спека: «из `*_original_value` и `inn_confidence`»; прочих полей в v5.18 сырыми нет | подсказка у поля старой формы беднее; формы старых времён на контуре удалены очисткой | +| P3-2 | `organization_name_modified` — `false` у всех форм (миграция и оба маппера), не `NULL` | как `inn_modified` в маппере 1.x; API отдаёт `bool` | одна строка `UPDATE` | +| P3-3 | `needs_review_fields`: маппер `1.x` пишет `[]`, миграция оставляет `NULL`, чтение `NULL` = `[]` | спека миграции поле не заполняет | нет | +| P3-4 | Маппер `1.x` тоже хранит вторую форму того же типа (Р-10); формы одного типа и одной пары `period_year`, `period_quarter` получают `MAPPING_WARNING` «в файле есть другая форма <тип> за тот же период» и по D-6 — `NEEDS_CHECK` (аналог Р-18) | частичный индекс v5.19 больше не заставляет пропускать; одно правило хранения для обоих MAJOR | O-1: если владелец хочет прежний пропуск — вернуть ветку `continue` | +| P3-5 | Предупреждения маппинга листа ОСВ (повтор счёта, строки без родителя, нарастающая по данным Q1) пишутся ещё и в `document_issue` документа листа (`warning`, `MAPPING_WARNING`, `info`, `core`) при любом MAJOR; строки в `metrics.mapping_warnings` остаются | спека: `DocumentOut.issues` ОСВ — «вместе с предупреждениями маппинга ядра этого листа» | нет | +| P3-6 | `file_ordinal` объединённого документа `DISCLOSURE` / `OTHER` — индекс первого такого документа в `documents[]` | несозданный документ «номер не занимает» | нет | +| P3-7 | `CODE_NOT_IN_DICTIONARY` и `CODE_NOT_IN_EDITION` — одна запись на код документа (`line_ids` — все строки `ok` с кодом), пятизначные коды тоже проверяются; при приёме `1.x` проверки ядра уровня `info` не пишутся | спека для `1.x` перечисляет только §7 и `MAPPING_WARNING` | O-3 (поток `info` у пятизначных) | +| P3-8 | После правки записи `core` `CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH` и `STATEMENTS_VERSION_MISMATCH` пересчитываются (заменяются); `NAME_MISMATCH` не ставится строке с `code_fixed`; `MAPPING_WARNING`, `RESULT_STATUS_INCONSISTENT`, `LEGACY_WARNING` сохраняются | код строки меняется правкой; страховка статуса — факт приёма | O-4 | +| P3-9 | Результат `2.0`: значение с `column_index` вне колонок формы и повтор `column_index` колонки — первое остаётся, `MAPPING_WARNING` | FK значения на колонку; уникальность `(meta_id, column_index)` | нет | +| P3-10 | Части `PATCH` применяются в порядке `lines` → `values` → `columns` → `header`; вся адресация (422) проверяется до первой записи; `UNKNOWN_CELL` по `line_code` — по кодам строк `ok` с учётом назначений `lines` этого запроса; один `line_id` дважды в `lines` — 422 `INVALID_LINE_PATCH` | атомарность и предсказуемость | нет | +| P3-11 | `reference_name` — первое имя кода в редакции ответа (`edition` по дате); нет редакции или кода — `null` | спека: «первое имя кода в словаре»; `edition` ответа — по дате | нет (Р-29) | +| P3-12 | Очистка отказывает, только если документ формы упомянут в `ex_snapshot_item` или привязан к версии в статусе «Верифицирована» или дальше: `VERIFIED` или `ARCHIVED` с `ex_snapshot.source_version_id` = версия; привязки к версиям в работе, на согласовании, `REJECTED`, `CANCELLED` (и архивированным отменённым) снимаются, статус версий не меняется | решение владельца 18.10.2026 (Р-35): верифицированная версия read-only и заморожена в ЕХ, прочие — нет; на стенде версий дальше «в работе» нет | архивированная версия без снимка ЕХ (из `CANCELLED`) очистке не мешает — если бы мешала, владелец увидел бы лишний отказ | +| P3-13 | Коды выхода `purge_statements.py`: `0` — отчёт или выполнено, `1` — отказ (п. 2 спеки), `2` — ошибка аргументов | как у `check_dbml_vs_orm.py` | нет | +| P3-14 | До части Б §7 считает ядро на лету (`statement_checks.yaml`) по агрегату; `checks.failed[].codes` берутся из правила, `line_ids` — по агрегату | часть А должна быть самосогласованной | временный код удаляется в B6 | +| P3-15 | Для условия `RECOGNIZED` `2.0` предупреждения файла берутся из `metrics.warnings` файла результата; в `recognition_job.metrics.warnings` — из конверта (inbox) | маппер читает файл результата; по контракту наборы одинаковы | нет | +| P3-16 | Подтверждение кода (`lines[] {confirmed: true}`) ставит `is_confirmed = true` и `needs_review = false`, `line_code_confidence` и `line_code_modified` **не меняет**; назначение кода — `confidence = 100`, `line_code_modified = true` | иначе исходная уверенность теряется, а снятие подтверждения не может вернуть `needs_review` (гайд §5.6.1: «при `false` — пересчитывается»); аналогия с ячейкой (`is_modified` не ставится); подтверждено владельцем 18.10.2026 (Р-34), спека `arm-core`, §9.5 п. 9 и гайд §5.6.1 приведены к этому | нет | +| P3-17 | Порядок строк ответа: строки `ok` (с шаблонными) — по коду, затем `row_index` (`NULL` последними), затем `id`; за каждой строкой `ok` источника — её «вложения» по `row_index`: строки `detail` с этим родителем и строки `unreadable` / `missing` / `detail` без родителя, у которых она — ближайшая предыдущая по `row_index` строка `ok` источника; вложения без такой строки — в начале | спека «Ответ `GET …/statement`» | нет | +| P3-18 | Изменение `crm_id` ГСК/ГСЗ пишет одно событие `GROUP_CHANGE` с `before/after` всех изменённых полей вместо `MEMBERSHIP_CHANGE` | спека: «изменение — аудит `GROUP_CHANGE`» | O-6 | +| P3-19 | Ядро зовёт у пакета `choose_edition` (`engine.edition`) и `status_input_from_contract` (`engine.contract_document_status`) вместо сборки `StoredForm` ради редакции и своего `status_input_from_v2`; `to_contract` ядру не нужен | одно правило с сервисом и с `recompute` (план 1, P1-25, P1-27); пары `(код, уверенный)` — из строк `ok` результата по `row_index` | при ошибке сборки пар редакция приёма разойдётся с редакцией после правки — тест B1 сравнивает их | +| P3-20 | `header.source.<поле>.confidence` — строка Decimal с двумя знаками (`"61.54"`), как прочие уверенности API; в JSONB `header_source` остаётся число как пришло | фронт читает все уверенности одним способом (план 6, A8) | округление до сотых в подсказке; исходное число — в БД | +| P3-21 | `StatementHeaderOut.okei` — из `financial_statement_meta.okei` (колонка есть с v5.18), только чтение; в `PATCH header` не входит | спека `header` контракта несёт `okei`, экран показывает `issues` с `field = "okei"` у поля | нет | +| P3-22 | Справочник строк для подсказки кода — поле `StatementOut.edition_lines [{code, name}]`, а не отдельный эндпоинт: данные уже загружены (`LineCatalog`, A6) при сборке ответа, нет нового маршрута, права, кэша и запроса на фронте; около 80 пар на форму (≈ 4 КБ) против строк формы с значениями; у формы без даты — коды всех редакций, по одному | наименьший объём реализации в ядре и во фронте; закрывает пробел «у формы без даты подсказки нет» (план 6, O-3) | ответ `GET` тяжелее на ≈ 4 КБ; если понадобится справочник вне формы — отдельный эндпоинт поверх того же `LineCatalog.edition_lines` | +| P3-23 | `DocumentBrief.issue_counts {error, info}` — все записи `document_issue` документа (и `relation` / `soft`, в том числе акцептованные) по `severity`; один групповой запрос на файл | панели файлов нужен признак «есть что смотреть» без запроса на каждый документ (план 6, O-4) | акцептованное нарушение продолжает помечать документ в панели — подробности на экране документа | +| P3-24 | Ошибки элемента `lines[]` (409 `LINE_CODE_TAKEN`, 422 `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`) несут в `detail` `line_id` и `line_code` первого неверного элемента; `LINE_CODE_REQUIRED` добавлен к четырём кодам запроса фронта | экран ставит ошибку у поля нужной строки, не угадывая (план 6, O-2); одно правило для всех ошибок элемента | при нескольких неверных элементах фронт видит только первый — как и прежде | + +## Открытые вопросы (`O-n`) + +- **O-1.** Р-10 для результатов `1.x` (P3-4): хранить вторую форму того же типа и при `1.x` или пропускать, как раньше? + План хранит. +- **O-2.** Закрыт решением владельца 18.10.2026 (Р-34): подтверждение кода уверенность не меняет (P3-16); буква спеки + `arm-core`, `05-domain-rules.md` §9.5 п. 9 и гайда §5.6.1 приведена к этому правилу. +- **O-3.** `CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` у пятизначных подстрок: каждая подстрока Excel даёт `info`. + Спека это требует (сценарий «Нестандартный код Excel»); план пишет по записи на код. Ограничить четырёхзначными? +- **O-4.** Пересчёт проверок ядра `info` после правки (P3-8) спека не описывает. +- **O-5.** Закрыт решением владельца 18.10.2026 (Р-35): отказ только при верифицированной версии (P3-12); спека `arm-core` + «Очистка форм перед выкладкой» п. 2 приведена к этому правилу, сценарий «Форма в версии на согласовании или + отклонённой» добавлен. +- **O-6.** Действие аудита при смене `crm_id` ГСК/ГСЗ (P3-18): сейчас правки ГСК/ГСЗ пишут `MEMBERSHIP_CHANGE`. +- **O-7.** Конверт с неизвестным MAJOR (`3.0`) inbox, как и сейчас, отбрасывает с предупреждением в лог (задание закроется + по сроку); сценарий спеки «файл получает `ERROR` с `mapping_error`» выполняется для файла результата. Оставить? +- **O-8.** Закрыт 18.10.2026 сведением с планом 1 (его P1-21…P1-31): (а) типы `Rules.code_orders.sequence(...) -> + tuple[str, ...]` (параметр `variant: FormVariant`, у `DISCLOSURE` — `()`), `.editions -> tuple[Edition, ...]`, + `Rules.checks.tolerance -> Decimal` записаны в API пакета; (б) редакция без пересчёта — `choose_edition` (`engine.edition`, + B4); (в) `resolve_relative(ABSOLUTE | NONE, …)` → `None`; (г) вход статуса из документа результата — + `status_input_from_contract` пакета (`engine.contract_document_status`), `status_input_from_v2` не пишется. +- **O-9.** Выкладка: PR части А в `main` без снимка контура до части Б; встроенный фронт `arm-core/web` получает новые типы + только через `sync-web` после плана 6 — выкладка ядра и фронта одним снимком. +- **O-10.** Метрики `fields_*` ядро сейчас не считает по строкам формы (копирует из конверта, `recognition_exchange.FIELD_COUNTERS`): + переводить на агрегат нечего. Подтвердить, что спека («метрики `fields_*`») не требует нового расчёта. + +--- + +## Часть А + +### Task A0: Change OpenSpec части А в arm-core + +**Files:** +- Create: `openspec/changes/unified-normalization-core-a/proposal.md`, `design.md`, `tasks.md`, + `specs/{data-model,osv-result-mapping,documents-api,primary-files,recognition-job-exchange,directories-api,statement-checks}/spec.md` + +**Interfaces:** +- Produces: change, на который ссылаются коммиты и PR части А; `design.md` с разделом `## Decisions made without + discussion` (туда переносятся `P3-n` части А и рулинги контроллера). + +- [ ] **Step 1:** `proposal.md` — ссылка на change docs (`unified-statement-normalization`, arm-ka-docs#116), перечень + требований спеки `arm-core`, которые закрывает часть А (по таблице «Покрытие спеки» ниже), и что остаётся части Б. +- [ ] **Step 2:** спеки-дельты: переносят в формулировках capability arm-core требования спеки `arm-core` docs из + колонки «А» таблицы покрытия (MODIFIED у `data-model`, `osv-result-mapping`, `documents-api`, `primary-files`, + `recognition-job-exchange`, `directories-api`; `statement-checks` — MODIFIED «§7 по агрегату по коду»). +- [ ] **Step 3:** `openspec validate unified-normalization-core-a --strict` — без ошибок. +- [ ] **Step 4: Commit** `openspec(core-a): change единой нормализации, часть А (arm-ka-docs#116)`. + +### Task A1: Схема v5.19 — модели и upgrade миграции + +**Files:** +- Modify: `api/src/models/statements.py`, `api/src/models/files.py`, `api/src/models/directories.py`, + `api/src/models/enums.py`, `api/src/models/__init__.py`, `api/src/repositories/statements.py` + (`delete_file_statements`), `api/tests/factories.py` (`make_document`), `Makefile`, `CLAUDE.md` +- Create: `api/alembic/versions/0007_dbml_v5_19.py` +- Test: `api/tests/test_migrations.py`, `api/tests/test_api_files_delete.py`, `api/tests/test_mapping_statements.py` + +**Interfaces:** +- Produces (ORM, имена колонок — как в DBML v5.19, новые колонки — в конце класса в порядке `ADD COLUMN` миграции): + - enum'ы `StatementLineStatus {ok, unreadable, missing, detail}`, `StatementFormVariant {full, simplified}`, + `DocumentIssueKind {warning, relation, soft}`, `IssueSeverity {error, info}`, `IssueSource {service, core}` — в + `ENUMS` под именами `statement_line_status`, `statement_form_variant`, `document_issue_kind`, `issue_severity`, + `issue_source`; + - `FinancialStatementLine`: `row_index: int | None`, `source_ref: str | None`, `line_status: StatementLineStatus` + (`server_default 'ok'`), `line_code_text: str | None`, `parent_row_index: int | None`, `needs_review: bool` + (`server_default false`), `is_confirmed: bool` (`server_default false`); индексы + `ix_financial_statement_line_meta_id_line_code` (не уникален) и `uq_financial_statement_line_meta_id_source_ref` + (`unique`, `postgresql_where=source_ref IS NOT NULL`); + - `FinancialStatementValue`: `source_text`, `needs_review` (`false`), `is_confirmed` (`false`), `confirmed_by` (FK + `app_user.id`), `confirmed_at`; + - `FinancialStatementColumn`: `period_kind: str | None`, `label_confidence: Decimal | None` (`Numeric(5,2)`); + - `FinancialStatementMeta`: `form_variant`, `needs_review_fields` (jsonb), `header_source` (jsonb), + `organization_name_original_value`, `organization_name_modified`; + - `Document.file_ordinal: int` (`SmallInteger`, `server_default 0`); `uq_document_file_type_account` — + `unique`, `postgresql_nulls_not_distinct=True`, `postgresql_where=document_type NOT IN ('BALANCE', 'INCOME')`; + `uq_document_stored_file_id_file_ordinal` (`stored_file_id`, `file_ordinal`, unique); + - `RecognitionJob.statements_version: str | None`; `RelatedGroup.crm_id: str | None` (`unique=True`); + - `DocumentIssue` (`document_issue`): поля DBML, `ix_document_issue_document_id`. +- Produces (репозиторий): `delete_file_statements(session, file_id)` удаляет и `document_issue` документов файла (первым). +- Produces (фабрика): `make_document(...)` без явного `file_ordinal` берёт следующий свободный номер в файле. + +- [ ] **Step 1: Write the failing test** — `test_upgrade_0007_backfills_v5_19_columns` в `test_migrations.py` (по образцу + `test_upgrade_0006_backfills_consolidation_id`): на ревизии `0006_dbml_v5_18` вставить ЮЛ, файл, `INCOME` + (`created_at` = t0) и `BALANCE` (t1 > t0), шапку ББ с `inn_original_value = "7701234567"`, `inn_confidence = 87`, + `report_date_original_value = "30.09.2025"`, `units_original_value = "тыс. руб."`, `organization_name = "ООО Ромашка"`; + колонки `c1` (`period_date = 2025-09-30`) и `c2` (`NULL`); строки `1600` и `1100` (оригинал = код) и `1250` + (`line_code_original_value = NULL`, сумма 5); значения: `(conf 100, rec true, mod false)`, `(conf 80, rec true, mod + false)`, `(conf 80, mod true)`, `(conf NULL, rec false)`, `(string_value "12 3")`; ГСК без `crm_id`. Затем `upgrade + head` и проверки: + +```python +assert rows["1100"].row_index == 0 and rows["1600"].row_index == 1 and rows["1250"].row_index is None +assert {r.line_status for r in rows.values()} == {"ok"} and not any(r.needs_review for r in rows.values()) +assert [v.needs_review for v in values] == [False, True, False, True, False] +assert value_by_text["12 3"].source_text == "12 3" +assert period_kind == {"c1": "absolute", "c2": "none"} and meta.form_variant is None +assert meta.header_source == { + "inn": {"text": "7701234567", "confidence": 87}, + "report_date": {"text": "30.09.2025", "confidence": None}, + "units": {"text": "тыс. руб.", "confidence": None}, + "organization_name": {"text": "ООО Ромашка", "confidence": None}, +} +assert meta.organization_name_original_value == "ООО Ромашка" and meta.organization_name_modified is False +assert ordinals == {"INCOME": 0, "BALANCE": 1} and related_group.crm_id is None +assert await count(conn, "document_issue") == 0 +``` + + И поведение схемы после upgrade (`test_v5_19_constraints`): вторая `BALANCE` в том же файле с `file_ordinal = 2` + вставляется; вторая `DISCLOSURE` — `IntegrityError` (`uq_document_file_type_account`); две строки с одним кодом в форме — + вставляются; две строки с одним непустым `source_ref` — `IntegrityError`; две с `source_ref = NULL` — вставляются; два + документа файла с одним `file_ordinal` — `IntegrityError`. +- [ ] **Step 2:** `make migration-cycle` — FAIL (ревизии `0007` нет). Константы теста: `HEAD = "0007_dbml_v5_19"`, + `V5_18 = "0006_dbml_v5_18"`; офлайн-SQL теста `test_offline_sql_creates_schema_before_version_table` доходит до `0007`. +- [ ] **Step 3: Implement** модели и `upgrade()` ревизии `0007_dbml_v5_19`: типы enum → колонки → заполнение → индексы + (уникальные — после заполнения `row_index`/`file_ordinal`) → таблица `document_issue`. Заполнение — SQL ревизии: + +```sql +-- row_index: строки источника по возрастанию line_code внутри формы с 0; созданные правкой — NULL +UPDATE {S}.financial_statement_line l SET row_index = n.rn FROM ( + SELECT id, row_number() OVER (PARTITION BY meta_id ORDER BY line_code, id) - 1 AS rn + FROM {S}.financial_statement_line WHERE line_code_original_value IS NOT NULL) n WHERE l.id = n.id; +UPDATE {S}.financial_statement_value SET source_text = string_value, + needs_review = NOT (is_modified OR (COALESCE(is_recognized, true) AND (confidence IS NULL OR confidence = 100))); +UPDATE {S}.financial_statement_column SET period_kind = CASE WHEN period_date IS NULL THEN 'none' ELSE 'absolute' END; +UPDATE {S}.document d SET file_ordinal = n.rn FROM ( + SELECT id, row_number() OVER (PARTITION BY stored_file_id ORDER BY created_at, id) - 1 AS rn FROM {S}.document) n + WHERE d.id = n.id; +``` + + `header_source` — `jsonb_build_object` по P3-1; `organization_name_original_value = organization_name`, + `organization_name_modified = false` (P3-2). Переключить `Makefile` (`DBML ?= ../../docs/model/data-model-v5.19.dbml`), + `DBML_VERSION = "5.19"`, `CLAUDE.md` («Правила репозитория»: v5.19, `0007_dbml_v5_19`). `make_document` — следующий + свободный `file_ordinal` в файле. `delete_file_statements` — `DELETE document_issue WHERE document_id IN (документы + файла)` первым запросом. +- [ ] **Step 4:** добавить в `test_delete_file_with_statement_form` строку `document_issue` у документа формы перед + удалением и проверку, что её нет после; в `test_repeated_form_mapping_is_idempotent` — строку `document_issue` до + повторного маппинга и проверку, что её нет после. +- [ ] **Step 5:** `make dbml-check` — пустой вывод, код 0. Если расхождение только в тексте `WHERE` из-за `\'` в note DBML — + в `scripts/dbml_parity.py::_norm_where` снимать `\` перед кавычкой, тест в `tests/test_dbml_parity.py` + (`document_type NOT IN (\'BALANCE\', \'INCOME\')` ≡ `document_type NOT IN ('BALANCE', 'INCOME')`). +- [ ] **Step 6:** `make check && make migration-cycle` — PASS (в т. ч. `test_autogenerate_finds_nothing_after_upgrade`, + `test_migrated_schema_equals_orm_create_all`). +- [ ] **Step 7: Commit** `feat(models): DBML v5.19 — колонки форм, document_issue, file_ordinal, crm_id ГСК (миграция 0007)`. + +### Task A2: Откат миграции v5.19 + +**Files:** +- Modify: `api/alembic/versions/0007_dbml_v5_19.py` (`downgrade`) +- Test: `api/tests/test_migrations.py` + +**Interfaces:** +- Consumes: ревизия A1. +- Produces: `downgrade()` — сначала проверки, потом DDL; при несовместимых данных `RuntimeError` с текстом + `откат v5.19 невозможен: <причина>: <число>[; <причина>: <число>…]`, причины дословно: `повтор кода строки в форме` + (число пар `(meta_id, line_code)` с повтором), `строки формы без кода`, `две формы одного типа в файле` (число пар + `(stored_file_id, document_type)` у `BALANCE`/`INCOME` с повтором), `crm_id ГСК/ГСЗ задан`, `наименование организации + правилось`. Журнал — `logging.getLogger("alembic.runtime.migration").warning("откат v5.19: удалены непустые значения — + %s", …)` со счётчиками `<таблица>.<колонка>: N` (булевы — число `true`, `file_ordinal` — число `<> 0`, прочие — `IS NOT + NULL`) и `document_issue: N`. + +- [ ] **Step 1: Write the failing tests** (по образцу `test_downgrade_one_step_fails_while_nu_rows_exist`): + `test_downgrade_0007_refuses_duplicate_code` (форма с двумя строками `2410`), `…_refuses_null_code` (строка + `unreadable` без кода), `…_refuses_two_forms_of_type` (две `BALANCE` в файле), `…_refuses_related_group_crm_id`, + `…_refuses_modified_organization_name`. В каждом: + +```python +with pytest.raises(AssertionError) as error: + await alembic(scratch_db_url, "downgrade", V5_18) +assert "откат v5.19 невозможен: повтор кода строки в форме: 1" in str(error.value) +assert await _scalars(conn, f"SELECT version_num FROM {SCHEMA}.alembic_version") == ["0007_dbml_v5_19"] +assert await count(conn, "financial_statement_line") == lines_before # данные не изменены +``` + + `test_downgrade_0007_drops_signals_and_logs_counts`: две ячейки `is_confirmed = true`, одна запись `document_issue`, без + несовместимых данных → откат проходит, в выводе alembic `financial_statement_value.is_confirmed: 2` и `document_issue: 1`; + после отката колонок v5.19 нет, вставка второй строки с тем же кодом в форму — `IntegrityError`; `upgrade head` снова + проходит. +- [ ] **Step 2:** `make migration-cycle` — новые тесты FAIL. +- [ ] **Step 3: Implement** `downgrade()`: пять `SELECT count(*)` до любого DDL; затем журнал; затем удаление таблицы + `document_issue`, индексов и колонок v5.19, восстановление `uq_financial_statement_line_meta_id_line_code` (unique) и + полного `uq_document_file_type_account` (`NULLS NOT DISTINCT`), удаление enum'ов. +- [ ] **Step 4:** `make migration-cycle` — PASS (полный цикл `upgrade → downgrade base → upgrade` тоже). +- [ ] **Step 5: Commit** `feat(migrations): откат v5.19 с отказом на несовместимых данных и журналом потерь`. + +### Task A3: `crm_id` ГСК/ГСЗ + +**Files:** +- Modify: `api/src/services/related_groups.py`, `api/src/schemas/directories.py`, `api/src/common/constraint_conflicts.py`, + `README.md` («Ошибки API») +- Test: `api/tests/test_api_related_group_crm_id.py` (переписать: сейчас проверяет «принимается и отбрасывается») + +**Interfaces:** +- Consumes: `RelatedGroup.crm_id` (A1). +- Produces: `RelatedGroupCreate.crm_id` / `RelatedGroupUpdate.crm_id` сохраняются (пустая строка после обрезки — `None`; + явный `null` в PATCH очищает); `RelatedGroupOut.crm_id` и `RelatedGroupDetail.crm_id` — из БД; конфликт — 409 + `CRM_ID_EXISTS` «ГСК/ГСЗ с таким crm_id уже существует» (проверка до вставки и запись `uq_related_group_crm_id` в + `CONSTRAINTS` на гонку); аудит изменения — P3-18. + +- [ ] **Step 1: Write the failing tests:** `test_create_related_group_stores_crm_id` (`POST` с `crm_id = " 51-03-0001 "` → + 201, `crm_id == "51-03-0001"`, `GET` деталей отдаёт его); `test_patch_related_group_crm_id_audited` (смена → событие + `GROUP_CHANGE`, `before_value["crm_id"] is None`, `after_value["crm_id"] == "77-02-0002"`); `test_crm_id_taken_is_409` + (вторая ГСЗ с тем же `crm_id`, в т. ч. в другой ГК → 409 `CRM_ID_EXISTS`, групп не прибавилось); + `test_patch_crm_id_null_clears`. +- [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_api_related_group_crm_id.py -v` — FAIL. +- [ ] **Step 3: Implement** — по образцу `CompanyGroupService._check_crm_id_free`; docstring схем — без «принимается и + отбрасывается». +- [ ] **Step 4:** тот же запуск — PASS; `make check` — PASS. +- [ ] **Step 5: Commit** `feat(directories): crm_id ГСК/ГСЗ сохраняется, 409 CRM_ID_EXISTS (arm-core#112)`. + +### Task A4: Очистка форм перед выкладкой `purge_statements.py` + +**Files:** +- Create: `api/scripts/purge_statements.py` +- Modify: `README.md` (раздел «Выкладка v5.19: очистка форм») +- Test: `api/tests/test_purge_statements.py` (маркер `integration`) + +**Interfaces:** +- Consumes: `files_repo.delete_file_rows`, `files_repo.expire_s3_objects`, `statements_repo.delete_file_statements` + (уже чистит `document_issue`, A1), `consolidation_documents.bound_versions` / `unbind_documents`, `AuditService.record`, + `ex_snapshot` (`source_version_id`). +- Produces: + - `@dataclass PurgePlan`: `form_only_files: list[uuid.UUID]`, `mixed_files: list[uuid.UUID]`, `form_documents: int`, + `confirmed_documents: int`, `unverified_bindings: int`, `blocking: list[BlockingVersion]` (`version_id`, `status`, + `document_id`), `superseded_with_osv: list[uuid.UUID]` (файлы, заменённые удаляемым файлом, в которых есть ОСВ); + - `async def plan_purge(session: AsyncSession, *, include_mixed: bool) -> PurgePlan` — только чтение; + - `async def apply_purge(session: AsyncSession, plan: PurgePlan, *, now: datetime) -> None`; + - `def render_report(plan: PurgePlan) -> str`; + - `async def run(argv: Sequence[str]) -> int` — `--dry-run` (по умолчанию), `--apply`, `--include-mixed`; коды P3-13; + запуск `cd api && .venv/bin/python -m scripts.purge_statements [--apply] [--include-mixed]`, одна транзакция. + +- [ ] **Step 1: Write the failing tests** (БД теста, фабрики `tests/factories.py`): + - `test_dry_run_reports_and_changes_nothing`: файл только с ББ+ОФР (ББ `CONFIRMED`, привязан к версии `INITIATED`), + смешанный файл (ОСВ + ББ), файл только с ОСВ → `run([])` → код 0; в отчёте строки `файлов только с формами: 1`, + `смешанных файлов: 1`, `документов форм: 3`, `подтверждённых документов: 1`, `привязок к неверифицированным версиям: 1`; число + строк всех таблиц не изменилось. + - `test_apply_refuses_verified_binding`: ББ привязан к версии `VERIFIED` → `run(["--apply"])` → код 1, в выводе id версии + и `VERIFIED`; база не изменена. То же для документа в `ex_snapshot_item.document_id` и для версии `ARCHIVED`, у которой + есть `ex_snapshot` с её `source_version_id`. + - `test_apply_ignores_unverified_versions` (сценарий «Форма в версии на согласовании или отклонённой», P3-12): ББ + привязан к версии `VERIFICATION_RF`, ОФР — к `REJECTED`, ещё одна форма — к `CANCELLED` и к `ARCHIVED` без + `ex_snapshot` → `run(["--apply"])` → код 0; привязки сняты, формы удалены, статусы всех версий прежние. + - `test_apply_deletes_form_only_file`: файл с ББ, ОФР, `DISCLOSURE`, комментарием к ББ, заданием; файл `old` с + `superseded_by_file_id` = удаляемый → после `--apply`: файла, документов, `financial_statement_*`, комментариев, заданий + нет; `old.superseded_by_file_id is None`, `old.status == SUPERSEDED`; `s3_object.expires_at` выставлен; привязка снята, + статус версии не изменился; событие `FILE_DELETE` с `user_id is None`, `description == "очистка форм перед v5.19"`, + `before_value` — карточка файла. + - `test_apply_mixed_keeps_osv_by_default`: смешанный файл → удалены только `BALANCE`/`INCOME` с `financial_statement_*`, + комментариями и привязками; файл, ОСВ (`osv_entry`), задания остаются; событие `FILE_DELETE` по файлу с + `after_value["deleted_documents"]` — список id; `--apply --include-mixed` — файл удалён целиком. + - `test_osv_and_directories_untouched`: число строк `osv_entry`, `consolidation*`, `correction`, `legal_entity`, + `audit_event` (кроме новых `FILE_DELETE`) не изменилось. + - `test_reupload_after_purge_not_duplicate` (сценарий «Очистка и повторная загрузка»): после `--apply` + `PrimaryFileService.find_duplicate(entity_id, sha256 удалённого файла)` — `None`. +- [ ] **Step 2:** `cd api && .venv/bin/pytest -m integration tests/test_purge_statements.py -v` — FAIL. +- [ ] **Step 3: Implement.** Отбор: документы `BALANCE`/`INCOME` у файлов любого статуса. Отказ — P3-12 и + `ex_snapshot_item`. Блокировки — порядок файл → документы → версии (`bound_versions`). Файлы только с формами — + `delete_file_rows` (без запрета `FILE_HAS_CONFIRMED_DOCUMENTS`), до него — обнуление `superseded_by_file_id` ссылающихся + файлов и `unbind_documents`; объекты S3 — `expire_s3_objects` по префиксам файла, как в `PrimaryFileService.delete`. + КФО в ядре ещё нет — пересчитывать нечего (написать в docstring). +- [ ] **Step 4:** тот же запуск — PASS; `make check` — PASS (`mypy src scripts`). +- [ ] **Step 5: Commit** `feat(scripts): purge_statements — очистка форм ББ/ОФР перед выкладкой v5.19 (Р-24, Р-27)`. + +### Task A5: Контракт 2.0 — копии, модели, приём конвертов, `JobBrief.warnings` + +**Files:** +- Modify: `api/contracts/**` (копии docs), `api/src/contract/common.py`, `api/src/contract/message.py`, + `api/src/contract/__init__.py`, `api/src/services/recognition_exchange.py`, `api/src/services/documents.py` + (`job_brief`), `api/src/schemas/files.py` +- Create: `api/src/contract/result_v2.py`, `api/src/contract/versions.py` +- Test: `api/tests/test_contract.py`, `api/tests/test_recognition_inbox.py`, `api/tests/test_api_files_read.py` + +**Interfaces:** +- Consumes: тест идентичности плана 0 (`api/tests/contract_identity.py`). +- Produces: + - `versions.major(schema_version: str) -> int`; `versions.parse_message(data: dict[str, Any]) -> + RecognitionResultMessage | RecognitionResultMessageV2` (MAJOR 1 / 2, иное — `ValidationError`); + `versions.parse_result(content: bytes) -> RecognitionResult | RecognitionResultV2` (то же); + - `result_v2`: `FileWarning {code, severity: Literal["error", "info"], message, locator?}`, `DocumentWarning {code, + severity, message, line_refs?, column_index?, field?}`, `RecognitionResultV2`, `StatementDocumentV2` + (`document_type: Literal["BALANCE", "INCOME", "DISCLOSURE"]`, `form_variant`, `status`, `warnings`, `header`, + `columns`, `lines`, `checks?`), `StatementHeaderV2` (10 полей + `<поле>_text`, `<поле>_confidence`, + `needs_review_fields`), `StatementColumnV2`, `StatementLineV2`, `StatementValueV2`, `StatementChecksV2`, + `FailedRelationV2`, `OsvDocumentV2` (поля `OsvDocument` + `status`, `warnings`), `MetricsV2` (`warnings: + list[FileWarning]`) — поля и обязательность по `recognition-result.schema.json` v2.0; + - `RecognitionResultMessageV2` — конверт v2.0 (`metrics: MetricsV2`); + - `recognition_job.metrics.warnings` пишется списком объектов `{code, severity, message, locator}`: у конверта `2.0` + — как пришли, у `1.x` — каждая строка как `{"code": "LEGACY_WARNING", "severity": "info", "message": s, "locator": + None}`; + - `schemas.files.FileWarningOut {code: str, severity: Literal["error", "info"], message: str, locator: str | None}`; + `JobBrief.warnings: list[FileWarningOut]`; `JobBrief.mapping_warnings` — по-прежнему `list[str]`; + - `documents.file_warnings(value: Any) -> list[FileWarningOut]` — строки → `LEGACY_WARNING`, объекты с `code`, + `severity ∈ {error, info}`, `message` — как есть, прочее пропускается. + +- [ ] **Step 1:** скопировать из `../docs/contracts/` (worktree `docs/` зеркала) в `api/contracts/`: основные схемы (результат + и конверт v2.0, запрос v1.0), `recognition-result.v1.schema.json`, `recognition-result-message.v1.schema.json`, + `examples/*.json`, `examples/v1/*.json`. `make check` — тест идентичности PASS, тесты примеров FAIL (модели v1). +- [ ] **Step 2: Write the failing tests:** таблица примеров `test_contract.py` разделена на v2 (`examples/*.json` → + схемы и модели v2) и v1 (`examples/v1/*.json` → схемы `.v1` и модели v1); `test_parse_message_by_major` (`"1.0"` → + `RecognitionResultMessage`, `"2.0"` → `RecognitionResultMessageV2`, `"3.0"` → `ValidationError`); + `test_inbox_ack_v2_moves_job_to_processing` (ack `processing` со `schema_version "2.0"` → задание `processing`); + `test_inbox_v1_warnings_wrapped` (конверт `1.0` с `metrics.warnings = ["sheet=ОСВ 60 строки без родителя"]` → + `job.metrics["warnings"] == [{"code": "LEGACY_WARNING", "severity": "info", "message": "sheet=ОСВ 60 строки без + родителя", "locator": None}]`); `test_inbox_v2_warnings_kept` (`JSON_FAILED`, `error`, `locator "file:a.json"` → как + пришло); `test_job_brief_converts_legacy_strings` (в БД `metrics.warnings = ["sheet=ОСВ 60 строки без родителя"]` → + `FileOut.job.warnings == [{"code": "LEGACY_WARNING", "severity": "info", "message": "sheet=ОСВ 60 строки без родителя", + "locator": None}]`, БД не изменилась); `test_job_brief_mixed_and_garbage_warnings` (`["a", {"code": "JSON_FAILED", + "severity": "error", "message": "m", "locator": "x"}, 5, {"code": "X"}]` → два элемента: `LEGACY_WARNING` «a» и + `JSON_FAILED`; `metrics.warnings = "строка"` → `[]`) — Review Focus 5. +- [ ] **Step 3:** `make check` — новые тесты FAIL. +- [ ] **Step 4: Implement.** `SCHEMA_VERSION_PATTERN` моделей v1 остаётся `^1\.\d+$`; у v2 — `^2\.\d+$`; + `RESULT_SCHEMA_VERSION_PATTERN` конверта v2 — `^\d+\.\d+$`. `recognition_exchange._parse_message` → `parse_message`; + `_apply_ack` и применение итога принимают оба класса конверта (общие поля одинаковы); `_metrics_json` оборачивает строки + `1.x`. `_load_result` в `result_mapping.py` в части А не меняется (файл `2.0` до части Б не мапится — `MappingFatal` + «результат не соответствует схеме»). +- [ ] **Step 5:** `make check` — PASS. +- [ ] **Step 6: Commit** `feat(contract): копии контракта обоих MAJOR, модели v2.0, приём конвертов 1.x/2.0, предупреждения файла структурами`. + +### Task A6: Словарь имён с синонимами и совпадение наименований + +**Files:** +- Modify: `api/src/domain/statement_lines.py`, `api/rules/statement_lines.yaml` +- Create: `api/src/domain/statement_names.py` +- Test: `api/tests/test_domain_statement_lines.py`, `api/tests/test_domain_statement_names.py` + +**Interfaces:** +- Produces: + - `LineCatalog.template_lines(edition: str | None, form: str) -> tuple[TemplateLine, ...]` — по одной строке на код, + первое имя, порядок первого появления (заменяет `lines()`; вызывающие — `services/statements.py`); + - `LineCatalog.reference_name(edition: str | None, form: str, code: str) -> str | None` — первое имя кода (P3-11); + - `LineCatalog.names(form: str, code: str) -> tuple[str, ...]` — все имена кода во **всех** редакциях; + - `LineCatalog.edition_codes(edition: str, form: str) -> frozenset[str]`; + - загрузчик не запрещает повтор кода в редакции; код — четыре цифры, как сейчас; + - `statement_names.normalize(text: str) -> str`; `statement_names.section_number(normalized: str) -> str | None`; + `statement_names.names_match(recognized: str | None, references: Iterable[str]) -> bool` (правило спеки «Совпадение + наименований»: нижний регистр, `ё` → `е`, всё кроме букв и цифр — пробел, схлопывание, `strip`; равенство, или более + короткая (≥ 5 символов) входит в более длинную, или `difflib.SequenceMatcher(None, a, b).ratio() >= 0.8`; «итого по + разделу» — сравнение номеров разделов из букв `i`, `v`, `x` (кириллические `і`, `х` → латиница), остаток слова + отбрасывается; номер не выделен — только равенство строк; пустое имя или нет справочных — `False`). + +- [ ] **Step 1: Write the failing tests:** + +```python +@pytest.mark.parametrize(("recognized", "references", "expected"), [ + ("Нематериальные активы", ["Результаты исследований и разработок"], False), + ("НДС, начисленный по отгрузке", ["Налог на добавленную стоимость по приобретенным ценностям", + "НДС, начисленный по отгрузке"], True), + ("Итого по разделу I", ["Итого по разделу II"], False), + ("Итого по разделу V1", ["Итого по разделу V"], True), + ("Итого по разделу V /", ["Итого по разделу V"], True), + ("Итого по разделу", ["Итого по разделу II"], False), + ("АКТИВ I. Внеоборотные активы Гудвил", ["Гудвил"], True), + ("Расчёты", ["Расчеты"], True), + ("", ["Запасы"], False), + ("Запасы", [], False), +]) +def test_names_match(recognized, references, expected): assert names_match(recognized, references) is expected +``` + + и `test_catalog_two_names_one_template_line` (`fsbu4`, `BALANCE`, код `1520` записан дважды: «Кредиторская + задолженность», «Обязательство по аренде» → словарь загружается, `template_lines` даёт одну `1520` с первым именем, + `names("BALANCE", "1520")` содержит оба); `test_catalog_fsbu4_additions` (в `fsbu4` есть вторые имена `1220` «НДС, + начисленный по отгрузке», `1520` «Обязательство по аренде», `2410` «Налог при упрощенной системе налогообложения» и коды + `1105` «Гудвил», `1130` «Нематериальные поисковые активы», `1140` «Материальные поисковые активы», `1530` «Доходы + будущих периодов», `2460` «Прочее»). +- [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_domain_statement_lines.py tests/test_domain_statement_names.py -v` — FAIL. +- [ ] **Step 3: Implement**; дополнения yaml — на места по `contracts/statement-code-orders.yaml` (порядок `fsbu4`), имена + — по `fixtures/ocr` и шаблонам банка; шапку yaml — «словарь имён: код → наименования по редакциям, несколько имён на код + (Р-20)». +- [ ] **Step 4:** тот же запуск и `make check` — PASS. +- [ ] **Step 5: Commit** `feat(domain): словарь имён с синонимами (Р-20) и совпадение наименований §11.9`. + +### Task A7: Агрегат формы по коду и перевод чтений «по коду» + +**Files:** +- Create: `api/src/domain/statement_aggregate.py` +- Modify: `api/src/repositories/statements.py` (`statement_amounts` → `form_aggregate`, `_line_amount`, `add_line`, + `load_form`), `api/src/services/statements.py` (`checks_report`, `statement_hard_failures`, `_resolve_cells`, + `build_out`), `api/src/services/result_mapping.py` (`_insert_statements`), `api/src/schemas/statements.py` + (`line_origin`), `api/src/domain/statement_checks.py` (`FailedCheck.codes`) +- Test: `api/tests/test_domain_statement_aggregate.py`, `api/tests/test_api_statements.py` + +**Interfaces:** +- Produces: + - `@dataclass(frozen=True) AggLine`: `id: uuid.UUID`, `row_index: int | None`, `line_status: StatementLineStatus`, + `line_code: str | None`, `amounts: Mapping[uuid.UUID, Decimal | None]` (ключ — `column_id`); + - `@dataclass(frozen=True) CodeAggregate`: `code: str`, `amounts: dict[uuid.UUID, Decimal | None]`, `line_ids: + tuple[uuid.UUID, ...]` (по `row_index`, `NULL` последними), `reference_name: str | None`, `has_source_line: bool`; + - `aggregate_by_code(lines: Iterable[AggLine], reference_name: Callable[[str], str | None] = lambda _: None) -> + dict[str, CodeAggregate]` — по возрастанию кода, только строки `ok`; сумма по колонке, `None` — если непустых нет; + - `repo.form_aggregate(session, meta_id, reference_name=...) -> tuple[list[FinancialStatementColumn], dict[str, + CodeAggregate]]`; + - `checks_input(columns, aggregates) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]` — вход + `run_checks` до части Б: коды без строки источника и без единой суммы пропускаются (правило D-5 шаблонных строк); + - `repo._line_amount` → `repo.code_amount(session, meta_id, code, *, current: bool = False, period_date=None) -> + Decimal | None` — `SUM(amount)` строк `ok` кода в колонке; + - `line_origin(line) -> "template"` ⇔ `line.row_index is None`; `repo.add_line` пишет `row_index = None`, `source_ref + = None`, `line_status = ok`, `line_code_original_value = None`; + - `FailedCheck.codes: tuple[str, ...]` (цель и члены правила, P3-14). + +- [ ] **Step 1: Write the failing tests** (домен): + +```python +def test_repeated_code_sums(): + agg = aggregate_by_code([ok("2410", r=1, c1="100"), ok("2410", r=5, c1="649")]) + assert agg["2410"].amounts[C1] == Decimal("749") and agg["2410"].line_ids == (L1, L5) +def test_empty_amounts_stay_null(): + assert aggregate_by_code([ok("1110", r=0, c1=None), ok("1110", r=3, c1=None)])["1110"].amounts[C1] is None + assert aggregate_by_code([ok("1110", r=0, c1=None), ok("1110", r=3, c1="5")])["1110"].amounts[C1] == Decimal("5") +def test_non_ok_lines_excluded(): + agg = aggregate_by_code([line("detail", None, r=2, c1="7"), line("unreadable", None, r=3, c1="9"), + line("missing", None, r=4, c1="1"), ok("1250", r=5, c1="3")]) + assert list(agg) == ["1250"] +def test_template_line_counts_as_ok(): + agg = aggregate_by_code([ok("1250", r=None, c1="10")]) + assert agg["1250"].amounts[C1] == Decimal("10") and agg["1250"].has_source_line is False +``` + + и API: `test_cross_form_uses_aggregate` (ББ: в колонке отчётной даты две строки `1370` — исходная с 500 и добавленная + строка `ok` с `row_index = 99`, 300; ОФР с `2400` = 800 − `1370(нач. года)` → `checks.cross_form is None`; при `2400` = + 500 − `1370(нач. года)` → `cross_form` с `expected`/`actual` по 800) — Review Focus 2; + `test_parent_code_with_repeated_parent` (две строки `1150`, подстрока `11501` → `parent_code == "1150"` один раз); + `test_template_not_completed_for_code_with_ok_line` и `test_template_completed_for_code_only_unreadable` (у формы нет + строки `ok` 1250, есть `unreadable` «ИЧ,250» → в ответе достроенная пустая `1250` с `id = null` и строка `unreadable` + отдельно); `test_patch_by_line_code_with_ok_line_is_unknown_cell` (код с существующей строкой `ok` → 422 `UNKNOWN_CELL`); + `test_origin_by_row_index` (строка источника с `line_code_original_value = null`, `row_index = 7` → `origin == + "source"`). +- [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_domain_statement_aggregate.py tests/test_api_statements.py -v` — FAIL. +- [ ] **Step 3: Implement**; удалить `statement_amounts` и `has_lines` — «есть строки» = есть строка с `row_index IS NOT + NULL` (`repo.has_source_lines`); `load_form` — строки по `line_code, row_index NULLS LAST, id`. +- [ ] **Step 4:** тот же запуск и `make check` — PASS (старые тесты `test_api_statements.py` про шаблонные строки зелёные). +- [ ] **Step 5: Commit** `feat(statements): агрегат формы по коду — проверки, межформенная, parent_code, достройка, правка по коду`. + +### Task A8: Ответ `GET …/statement` и `DocumentOut.issues` по v5.19 + +**Files:** +- Create: `api/src/domain/statement_layout.py` +- Modify: `api/src/schemas/statements.py`, `api/src/schemas/documents.py`, `api/src/schemas/files.py` (`DocumentBrief`), + `api/src/services/statements.py` (`build_out`), `api/src/services/documents.py` (`document_out`, `file_out` — `issue_counts` рядом с `counts` комментариев), + `api/src/services/primary_files.py` (сборка `FileOut.documents`), `api/src/repositories/statements.py` (`issues`, + `issue_counts_by_document`), `api/src/domain/statement_lines.py` (`LineCatalog.edition_lines`) +- Test: `api/tests/test_domain_statement_layout.py`, `api/tests/test_api_statements.py`, `api/tests/test_api_documents.py`, + `api/tests/test_api_files_read.py`, `api/tests/test_domain_statement_lines.py` + +**Interfaces:** +- Consumes: `CodeAggregate` (A7), `LineCatalog.reference_name` (A6). +- Produces: + - `@dataclass(frozen=True) LayoutLine`: `key: Hashable`, `row_index: int | None`, `line_status: str`, `line_code: str | + None`, `parent_row_index: int | None`; `order_lines(lines: Sequence[LayoutLine]) -> list[Hashable]` — P3-17; + - `repo.issues(session, document_id) -> list[DocumentIssue]` (по `created_at, id`); + - `IssueOut {id, kind, code, severity, message, line_ids: list[uuid.UUID], column_id, column_index, field, source, + relation, codes: list[str], expected, actual}` — общий для `StatementOut.issues` и `DocumentOut.issues`; + - `StatementDocumentOut.form_variant`; `StatementHeaderOut.needs_review_fields: list[str]`, `source: dict[str, + HeaderSourceOut]` (= `header_source` или `{}`; `HeaderSourceOut {text: str | None, confidence: Decimal | None}` — + число из JSONB приводится `Decimal(str(x)).quantize(Decimal("0.01"))` и отдаётся строкой, как прочие уверенности + API, P3-20), `organization_name_modified: bool`, `okei: str | None` (`financial_statement_meta.okei`, P3-21); + `ColumnOut.period_kind`, `label_confidence`; `LineOut` + `row_index`, `source_ref`, `line_status`, `line_code_text`, + `line_code_confidence: Decimal | None` (строка), `reference_name`, `parent_row_index`, `needs_review`, + `is_confirmed`, `line_code_modified`; `ValueOut` + `text` (`source_text`), `needs_review`, `is_confirmed`; + `FailedCheckOut` + `codes`, `line_ids`; `StatementOut.issues: list[IssueOut]`; + - `StatementOut.edition_lines: list[EditionLineOut]`, `EditionLineOut {code: str, name: str}` — справочник строк для + подсказки кода (P3-22): строки словаря имён формы `document.document_type` в редакции ответа (`edition`), по одной + на код, имя — первое (как `reference_name`), в порядке словаря; `edition is None` — строки всех редакций словаря по + порядку редакций, по одной на код (имя — из первой редакции, где код есть); `LineCatalog.edition_lines(form: str, + edition: str | None) -> list[tuple[str, str]]`; + - `DocumentOut.issues: list[IssueOut]` (у любого документа); `DocumentOut.warnings` остаётся; + - `DocumentBrief.issue_counts: IssueCountsOut {error: int, info: int}` (а значит и `DocumentOut.issue_counts`) — число + записей `document_issue` документа по `severity`, всех видов (`warning`, `relation`, `soft`), P3-23; + `repo.issue_counts_by_document(session, document_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, IssueCounts]` — один + запрос `GROUP BY document_id, severity` на список документов (как `comments_count_by_document`); документа нет в + ответе — `{error: 0, info: 0}`. + +- [ ] **Step 1: Write the failing tests:** + +```python +def test_order_lines_attachments(): + lines = [L("m0", 0, "missing", None, None), L("1230", 1, "ok", "1230", None), L("u2", 2, "unreadable", None, None), + L("1210", 3, "ok", "1210", None), L("d4", 4, "detail", None, 3), L("t1250", None, "ok", "1250", None), + L("2410b", 9, "ok", "2410", None), L("2410a", 6, "ok", "2410", None)] + assert order_lines(lines) == ["m0", "1210", "d4", "1230", "u2", "t1250", "2410a", "2410b"] +def test_detail_without_parent_follows_previous_ok(): + assert order_lines([L("1110", 0, "ok", "1110", None), L("d1", 1, "detail", None, None)]) == ["1110", "d1"] +``` + + API: `test_get_statement_v5_19_fields_on_1x_form` (форма из фикстуры `1.2`: у строк `line_status == "ok"`, + `row_index` — целые с 0, `source_ref is None`, `origin == "source"`, `needs_review is False`, `is_confirmed is False`; у + значений `text == string_value`; `document.form_variant is None`; `header.needs_review_fields == []`; + `header.organization_name_modified is False`; `columns[].period_kind ∈ {"absolute", "none"}`; `issues == []`); + `test_reference_name_first_name` (`fsbu4`, строка `1520` → `reference_name == "Кредиторская задолженность"`); + `test_issues_in_statement_and_document` (вставленная запись `document_issue` `warning` `COLUMN_SKIPPED` с `column_index = + 5`, `column_id = null` → она в `StatementOut.issues` и `DocumentOut.issues` с этими полями); + `test_line_code_confidence_and_okei` (строка с `line_code_confidence = 67.50` → `"67.50"`; `meta.okei = "384"` → + `header.okei == "384"`); `test_header_source_confidence_is_decimal_string` (`header_source = {"inn": {"text": "x", + "confidence": 61.538464}, "units": {"text": "тыс. руб.", "confidence": 100}}` → `header.source.inn.confidence == + "61.54"`, `header.source.units.confidence == "100.00"`; `confidence: null` → `null`); + `test_edition_lines` (форма `fsbu4` ББ → `edition_lines` — коды редакции `fsbu4` ББ словаря имён по одному, `1520` с + именем «Кредиторская задолженность», `1120` нет; форма без даты (`edition is None`) → коды обеих редакций, `1120` + есть, повторов кода нет); `test_issue_counts_in_file_and_document` (у ББ записи `warning` `error`, `relation` `error`, + `soft` `info`, у ОФР нет → в `GET /files/{id}` `documents[]`: ББ `issue_counts == {"error": 2, "info": 1}`, ОФР `{"error": + 0, "info": 0}`; `GET /documents/{id}` — те же числа; число SQL-запросов на `GET /files` не растёт с числом документов). +- [ ] **Step 2:** запуск трёх файлов — FAIL. +- [ ] **Step 3: Implement**; порядок `lines` ответа — `order_lines` (достроенные шаблонные — как строки `ok` без + `row_index`). +- [ ] **Step 4:** запуск и `make check` — PASS. +- [ ] **Step 5: Commit** `feat(api): StatementOut и DocumentOut по v5.19 — статусы строк, needs_review, issues, порядок строк`. + +### Task A9: Маппер `1.x` — поля v5.19, `file_ordinal`, вторая форма, `MAPPING_WARNING` + +**Files:** +- Create: `api/src/domain/statement_issues.py` +- Modify: `api/src/domain/statement_mapping.py`, `api/src/repositories/statements.py` (`insert_statement`, + `add_issues`), `api/src/services/result_mapping.py` (`map_file`, `_map_statement_docs`, `_insert_statements`) +- Test: `api/tests/test_domain_statement_mapping.py`, `api/tests/test_mapping_statements.py`, `api/tests/test_result_mapping.py` + +**Interfaces:** +- Produces: + - `@dataclass(frozen=True) IssueDraft`: `kind: DocumentIssueKind`, `code: str`, `severity: IssueSeverity`, `message: str`, + `source: IssueSource`, `line_ids: tuple[uuid.UUID, ...] = ()`, `column_id: uuid.UUID | None = None`, `column_index: int + | None = None`, `field: str | None = None`, `relation: str | None = None`, `codes: tuple[str, ...] = ()`, `expected: + Decimal | None = None`, `actual: Decimal | None = None`; `mapping_warning(message: str) -> IssueDraft`; + - `repo.add_issues(session, document_id: uuid.UUID, drafts: Sequence[IssueDraft], *, now) -> None`; + - `LineDraft` + `row_index: int` (индекс строки в `doc.lines` результата), `line_status = "ok"`, + `line_code_original_value`, `source_ref = None`, `line_code_text = None`, `needs_review = False`; `ValueDraft` + + `source_text` (= `string_value`), `needs_review` (= НЕ `value_clean(confidence, is_recognized)`); колонки + `period_kind` + (`absolute` при `period_date`, иначе `none`), `label_confidence = None`; шапка + `header_source` (10 полей шапки + результата: `text` из одноимённого поля 1.x, у `report_date` — `report_date_text`, `confidence` — где есть в 1.x, у + `okei` оба `None`), `organization_name_original_value`, `organization_name_modified = False`, `needs_review_fields = + []`, `form_variant = None`; + - `value_clean(confidence: Decimal | None, is_recognized: bool | None) -> bool` (выделено из `values_ok`); + - каждый создаваемый документ — `file_ordinal` = индекс в `result.documents` (P3-6 для `DISCLOSURE`/`OTHER`); + - предупреждения черновика формы → `MAPPING_WARNING` документа; предупреждения листа ОСВ — тоже (P3-5); Р-10 — P3-4. + +- [ ] **Step 1: Write the failing tests:** + - `test_two_osv_sheets_file_ordinals` — результат `1.x` с ОСВ 60 и 62 → `file_ordinal` 0 и 1, файл `PROCESSED`; + - `test_pdf_balance_income_1x_ordinals` — `1.2` с `BALANCE`, `INCOME` → 0 и 1; + - `test_duplicate_osv_sheet_takes_no_ordinal` — ОСВ 60, ОСВ 60, ББ → 0 и 2; + - `test_1x_line_fields` — строки 0..n: `row_index == i`, `line_status == "ok"`, `line_code_original_value == line_code`, + `source_ref is None`; значения `confidence 100, is_recognized true` → `needs_review is False`, `confidence 80` → + `True` (сценарий «Полярность needs_review при приёме 1.x»); `source_text == string_value`; + - `test_1x_duplicate_code_keeps_first` — повтор `2410` в `1.2`: хранится первая строка, `row_index` второй не занят, + `document_issue` `MAPPING_WARNING` с текстом о повторе; + - `test_1x_second_form_same_period_stored` (заменяет `test_duplicate_form_in_file_skipped`) — два `BALANCE` на 30.09.2025 + → два документа, оба `NEEDS_CHECK`, у каждого `MAPPING_WARNING` «в файле есть другая форма BALANCE за тот же период», + ни один не `CONFIRMED` — Review Focus 1; + - `test_1x_second_form_other_period` — `BALANCE` на 30.09.2025 и на 31.12.2024 → у каждого свой статус, предупреждения + о повторе нет; + - `test_osv_orphans_mapping_warning_issue` — лист с сиротами → у документа ОСВ `MAPPING_WARNING` с текстом сирот, + строка в `mapping_warnings` тоже есть; + - `test_1x_header_source` — `header_source["inn"] == {"text": inn, "confidence": inn_confidence}`, + `header_source["report_date"]["text"] == report_date_text`. +- [ ] **Step 2:** запуск трёх файлов — FAIL. +- [ ] **Step 3: Implement**; статус форм `1.x` — D-6, как сейчас (`values_ok` = нет `needs_review` у значений; + `warnings` = предупреждения черновика + повтор периода); `insert_statement` ключует строки по `row_index`, а не по коду. +- [ ] **Step 4:** запуск, `make check` и `cd api && .venv/bin/pytest -m "integration and fixtures" tests/test_mapping_statements.py tests/test_mapping_golden.py` — PASS + (golden-снимки ОСВ и все снимки форм `1.x` маппятся). +- [ ] **Step 5: Commit** `feat(mapping): маппер 1.x пишет поля v5.19, file_ordinal, хранит вторую форму, MAPPING_WARNING в document_issue`. + +### Task A10: Условие `RECOGNIZED` и статус ОСВ при приёме 2.0 — чистые функции + +**Files:** +- Create: `api/src/domain/statement_status.py` +- Test: `api/tests/test_domain_statement_status.py` + +**Interfaces:** +- Consumes: `names_match`, `LineCatalog.names` (A6). +- Produces (подключаются в B2, B4, B5): + - `has_error_warning(warnings: Any) -> bool` — есть элемент-объект с `severity == "error"`; строки и мусор — нет; + - `duplicate_period_forms(forms: Iterable[tuple[Hashable, DocumentType, int | None, PeriodQuarter | None]]) -> + set[Hashable]` — ключи форм `BALANCE`/`INCOME`, у которых в файле есть другая форма того же типа с той же парой (год, + квартал); формы без квартала не считаются; + - `@dataclass(frozen=True) NameCheckLine`: `key`, `line_status`, `line_code`, `line_code_confidence: Decimal | None`, + `line_name: str | None`, `code_fixed: bool`; `name_mismatches(lines: Iterable[NameCheckLine], catalog: LineCatalog, + form: str) -> list[Hashable]` — строки `ok` с `line_code_confidence < 100`, без `code_fixed`, чьё имя не совпало ни с + одним `catalog.names(form, code)`; + - `form_status_v2(*, service_done: bool, file_error_warning: bool, counterparty: DocumentCheckResult | None, + period_known: bool, names_ok: bool, duplicate_in_file: bool) -> DocumentStatus`; + - `osv_status_v2(*, osv_done: bool, checks_ok: bool, file_error_warning: bool, counterparty: DocumentCheckResult | + None, sheet_mapping_warnings: bool) -> DocumentStatus`. + +- [ ] **Step 1: Write the failing tests:** + +```python +OK = dict(service_done=True, file_error_warning=False, counterparty=DocumentCheckResult.MATCH, + period_known=True, names_ok=True, duplicate_in_file=False) +def test_form_recognized(): assert form_status_v2(**OK) is DocumentStatus.RECOGNIZED +@pytest.mark.parametrize("change", [{"service_done": False}, {"file_error_warning": True}, + {"counterparty": DocumentCheckResult.MISMATCH}, {"period_known": False}, {"names_ok": False}, + {"duplicate_in_file": True}]) +def test_form_needs_check(change): assert form_status_v2(**OK | change) is DocumentStatus.NEEDS_CHECK +def test_unknown_counterparty_allowed(): + assert form_status_v2(**OK | {"counterparty": None}) is DocumentStatus.RECOGNIZED +def test_duplicate_period_forms(): + forms = [(1, BAL, 2025, Q3), (2, BAL, 2025, Q3), (3, BAL, 2024, Q4), (4, INC, 2025, Q3), (5, BAL, 2025, None), + (6, BAL, 2025, None)] + assert duplicate_period_forms(forms) == {1, 2} +def test_has_error_warning(): + assert has_error_warning([{"code": "JSON_FAILED", "severity": "error", "message": "m"}]) + assert not has_error_warning([{"code": "LEGACY_WARNING", "severity": "info", "message": "m"}, "строка"]) + assert not has_error_warning(None) +``` + + `osv_status_v2`: все условия — `RECOGNIZED`; каждое нарушенное — `NEEDS_CHECK` (параметризация, как у форм). + `name_mismatches` по `fsbu4`: `1120` (50) «Нематериальные активы» → в списке; `1220` (50) «НДС, начисленный по + отгрузке» → нет; `1200` (50) «Итого по разделу I» → в списке; `1500` (56) «Итого по разделу V1» → нет; `1105` (67) + «АКТИВ I. Внеоборотные активы Гудвил» → нет; `1120` (50) с `code_fixed` → нет; `1110` (100) с чужим именем → нет; + `9999` (50) → в списке (кода нет в словаре); пустое имя (50) → в списке. +- [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_domain_statement_status.py -v` — FAIL. +- [ ] **Step 3: Implement** (docstring — §9.3 «Документ формы при приёме контракта v2.0», «Документ ОСВ при приёме контракта v2.0»). +- [ ] **Step 4:** запуск и `make check` — PASS. +- [ ] **Step 5: Commit** `feat(domain): условие RECOGNIZED для 2.0 (Р-16, Р-18, Р-19) и статус ОСВ (Р-28)`. + +### Task A11: Подтверждение формы — порядок 409 и автоподтверждение + +**Files:** +- Modify: `api/src/services/statements.py` (`form_confirmable` → `form_blocker`), `api/src/services/documents.py` + (`_check_statement`), `api/src/services/auto_confirm.py`, `api/src/repositories/statements.py`, + `api/src/repositories/files.py` (`confirmed_twin`), `README.md` +- Test: `api/tests/test_api_statements.py`, `api/tests/test_form_confirmable.py`, `api/tests/test_auto_confirm.py` + +**Interfaces:** +- Consumes: агрегат и `statement_hard_failures` (A7). +- Produces: + - `@dataclass(frozen=True) FormBlocker`: `code: str`, `message: str`, `extra: dict[str, Any]`; + - `async def form_blocker(session, document, meta) -> FormBlocker | None` — первый по порядку `PERIOD_REQUIRED` → + `LINES_UNRESOLVED` (`extra = {"lines": [{"line_id", "row_index", "line_code_text"}]}`, строки `unreadable`/`missing`, + у которых хотя бы в одной ячейке `amount` или `string_value` не `null`, по `row_index`) → `DUPLICATE_FORM` (`extra = + {"document_id": <подтверждённая форма того же файла, типа, года, квартала>}`) → `CHECKS_NOT_PASSED` (`extra = + {"failures": [...]}`, как сейчас); + - `repo.unresolved_lines(session, meta_id) -> list[tuple[uuid.UUID, int | None, str | None]]`; + - `files_repo.confirmed_twin(session, document) -> uuid.UUID | None`; + - `DocumentService._check_statement` поднимает `ConflictError(blocker.code, blocker.message, **blocker.extra)`; + `auto_confirm` подтверждает форму, только если `form_blocker(...) is None` (`form_confirmable` удаляется). + +- [ ] **Step 1: Write the failing tests** (строки вставляются ORM-фабрикой на форме из фикстуры): + - `test_confirm_unresolved_line_with_amount` — строка `missing` (`row_index = 40`) с суммой и жёсткое нарушение → + 409 `LINES_UNRESOLVED`, `detail.lines == [{"line_id": id, "row_index": 40, "line_code_text": None}]`; + - `test_confirm_unresolved_line_with_text_only` — `unreadable`, `line_code_text = "ИЧ,250"`, суммы пустые, + `string_value = "211(1 |"` → 409 `LINES_UNRESOLVED`; + - `test_unresolved_line_without_values_does_not_block` — `unreadable` без значений → подтверждение проходит (при прочих + условиях); + - `test_confirm_duplicate_form` — два ББ на 30.09.2025 в файле, первый `CONFIRMED` → второй: 409 `DUPLICATE_FORM`, + `detail.document_id == str(first.id)` — Review Focus 1; + - `test_conflict_order` — `PERIOD_REQUIRED` раньше `LINES_UNRESOLVED`; `LINES_UNRESOLVED` раньше `DUPLICATE_FORM`; + `DUPLICATE_FORM` раньше `CHECKS_NOT_PASSED`; + - `test_auto_confirm_skips_blocked_form` — форма `RECOGNIZED` со строкой `missing` с суммой → остаётся `RECOGNIZED`. +- [ ] **Step 2:** запуск трёх файлов — FAIL. +- [ ] **Step 3: Implement**; README «Ошибки API» — `LINES_UNRESOLVED`, `DUPLICATE_FORM`. +- [ ] **Step 4:** запуск и `make check` — PASS. +- [ ] **Step 5: Commit** `feat(documents): порядок 409 подтверждения формы — LINES_UNRESOLVED, DUPLICATE_FORM`. + +### Task A12: `PATCH …/statement` — подтверждение ячейки, дата колонки, шапка + +**Files:** +- Modify: `api/src/schemas/statements.py` (`CellPatch`, `HeaderPatch`, `ColumnPatch`, `StatementPatch`), + `api/src/services/statements.py` (`patch`, `_apply_cells`, `_apply_header`, `_apply_columns`, `recompute`), + `api/src/domain/statement_issues.py` (правила снятия), `api/src/repositories/statements.py` (`delete_issues`), + `README.md` +- Test: `api/tests/test_api_statements.py` + +**Interfaces:** +- Consumes: `IssueDraft`, `repo.add_issues` (A9), агрегат (A7). +- Produces: + - `CellPatch`: `line_id | line_code`, `column_id`, `amount` (необязательно, наличие — `model_fields_set`), `confirmed: + bool | None`; ровно одно из `amount`/`confirmed` (иначе 422 pydantic); `confirmed` только с `line_id` (иначе 422 + pydantic); + - `HeaderPatch.organization_name: Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, + max_length=1000)] | None`; + - `ColumnPatch {column_id: uuid.UUID, period_date: datetime.date}` (`null` — 422 pydantic); `StatementPatch.columns: + list[ColumnPatch]` (`max_length=100`); + - `statement_issues.HEADER_CLEARS: dict[str, frozenset[str]]` = `{"report_date": {"PERIOD_UNKNOWN", + "REPORT_DATE_UNPARSED", "REPORT_DATE_UNCERTAIN"}, "inn": {"NO_ORGANIZATION_NO_INN"}, "organization_name": + {"NO_ORGANIZATION_NO_INN"}}`, `COLUMN_CLEARS = frozenset({"COLUMN_WITHOUT_DATE", "COLUMN_DATE_UNCERTAIN"})`; + - `repo.delete_issues(session, document_id, *, codes: Collection[str], kind: DocumentIssueKind | None = None, source: + IssueSource | None = None, column_id: uuid.UUID | None = None) -> int`; + - `StatementService.recompute(document, meta) -> None` — единая точка пересчёта после правки (заменяет + `recompute_status`); до части Б: D-6 с `values_ok` = нет `needs_review` у значений строк не `detail`; в Б тело + меняется (B2), сигнатура — нет; + - сброс акцепта — только правкой данных (сумма, код, статус строки, дата колонки, поле шапки); подтверждения — нет. + +- [ ] **Step 1: Write the failing tests:** + - `test_confirm_cell_without_change` — ячейка `needs_review = true`, акцепт стоит → `values: [{line_id, column_id, + confirmed: true}]` → 200; `is_confirmed`, `confirmed_by == user`, `confirmed_at` задан, `is_modified is False`, + `needs_review is False`, сумма прежняя, одно событие `RECOGNIZED_DATA_EDIT`, `checks.accepted` не `null`; + - `test_unconfirm_cell` — снять (`confirmed: false`) с ячейки `confidence 80` → `is_confirmed is False`, + `confirmed_by is None`, `needs_review is True`, аудит; + - `test_cell_amount_and_confirmed_is_422`, `test_cell_confirmed_by_line_code_is_422` (массив), `test_confirm_missing_cell_unknown` + (строка есть, записи значения нет → 422 `UNKNOWN_CELL`); + - `test_patch_organization_name` — запись `NO_ORGANIZATION_NO_INN`, у шапки нет ИНН, имя владельца «ООО Ромашка» → + `header: {organization_name: "ООО Ромашка"}` → `organization_name_modified is True`, + `organization_name_original_value` прежнее, `counterparty_check == "MATCH"`, записи нет, аудит `before/after` по + `organization_name`, акцепт сброшен; `""` и строка из 1001 символа — 422 массивом; + - `test_confirm_report_date` — `needs_review_fields = ["report_date"]`, запись `REPORT_DATE_UNCERTAIN` → тот же + `report_date` → `needs_review_fields == []`, записи нет, аудит, акцепт сохранён; тот же `report_date` без + `needs_review_fields` → 200, событий нет; + - `test_report_date_change_clears_warnings` — записи `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, `REPORT_DATE_UNCERTAIN`, + `SEGMENT_COLUMNS_MISMATCH` → смена даты → остаётся только `SEGMENT_COLUMNS_MISMATCH`; + - `test_inn_change_clears_no_organization`; + - `test_column_date` — колонка без даты с `COLUMN_WITHOUT_DATE` (`column_id`), `report_date = null` → `columns: + [{column_id, period_date: "2024-12-31"}]` → `period_date`, `period_kind == "absolute"`, записи нет, `is_current` + пересчитан, `document.reporting_date == 2024-12-31` и квартал `Q4` (§9.5 п. 2), акцепт сброшен; + - `test_column_date_null_is_422`, `test_unknown_column_is_422` (колонка другой формы → `UNKNOWN_COLUMN`); + - `test_confirm_column_date` — `COLUMN_DATE_UNCERTAIN`, та же дата → записи нет, `period_kind == "absolute"`, аудит, + акцепт сохранён; та же дата без предупреждения → 200 без событий; + - `test_relative_column_becomes_absolute` — колонка `relative:prev_year_end` → дата 31.12.2024 → `period_kind == + "absolute"` (вторая половина сценария — B2); + - `test_segment_warning_stays_after_value_edit` — `SEGMENT_COLUMNS_MISMATCH` остаётся после правки суммы, ручное + подтверждение документа проходит. +- [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_api_statements.py -v` — новые FAIL. +- [ ] **Step 3: Implement**; порядок частей — P3-10; README «Ошибки API» — `UNKNOWN_COLUMN`. +- [ ] **Step 4:** запуск и `make check` — PASS. +- [ ] **Step 5: Commit** `feat(statements): PATCH — подтверждение ячейки, дата колонки, наименование организации, подтверждение даты`. + +### Task A13: `PATCH …/statement` — строки (`lines`) + +**Files:** +- Create: `api/src/services/statement_line_edits.py` +- Modify: `api/src/schemas/statements.py` (`LinePatch`, `StatementPatch.lines`), `api/src/services/statements.py` + (`patch`), `api/src/domain/statement_layout.py` (`nearest_ok_parents`), `api/src/repositories/statements.py` + (`delete_line`, `remove_line_from_issues`), `README.md` +- Test: `api/tests/test_api_statement_lines.py`, `api/tests/test_domain_statement_layout.py` + +**Interfaces:** +- Consumes: `StatementService.recompute`, `_editable`, аудит (A12). +- Produces: + - `LinePatch {line_id: uuid.UUID, line_code: str | None = None, line_status: Literal["detail", "ok"] | None = None, + confirmed: bool | None = None}` (`extra="forbid"`; `line_code` до 10 символов); `StatementPatch.lines: + list[LinePatch]` (`max_length=5000`); + - `async def apply_line_edits(session, *, document, meta, items: Sequence[LinePatch], user_id, before: dict, after: + dict) -> bool` (возвращает «были правки данных» — для сброса акцепта): проверки до записи — `INVALID_LINE_PATCH` (не + ровно одно действие; один `line_id` дважды), `UNKNOWN_LINE`, `LINE_CODE_REQUIRED` (`line_status == "ok"`), + `INVALID_LINE_CODE` (не `^\d{4,5}$`), затем `LINE_CODE_TAKEN` (строка `row_index IS NULL`, `line_status = ok`, с тем + же кодом и хотя бы одной непустой `amount`; 409, ничего не изменено); у всех пяти ошибок `detail` несёт `line_id` и + `line_code` первого неверного элемента `lines[]` по порядку запроса (`line_id` — как в элементе, у `UNKNOWN_LINE` — + чужой id; `line_code` — код элемента или `null`; у `LINE_CODE_TAKEN` — строка, которой назначается код, и этот код; + P3-24); затем запись: удаление пустых шаблонных строк + назначаемых кодов (значения → строка → `id` из `line_ids` записей документа), назначение (`line_status = ok`, + `line_code`, `line_code_confidence = 100`, `line_code_modified = true`, `needs_review = false`), отметка `detail` + (`line_code = null`), подтверждение (P3-16), пересчёт `parent_row_index` всех строк `detail`; + - `nearest_ok_parents(lines: Sequence[LayoutLine]) -> dict[Hashable, int | None]` — для строк `detail`: `row_index` + ближайшей строки `ok` выше (строки без `row_index` родителями не бывают); временная, в B2 заменяется + `detail_parents` пакета; + - `repo.remove_line_from_issues(session, document_id, line_id) -> None` — `line_ids = line_ids - '<id>'` у всех записей + документа. + +- [ ] **Step 1: Write the failing tests:** + - `test_assign_code_to_garbage_line` (AURORA-1462) — `unreadable` «ИЧ,250», `row_index = 2` → `lines: [{line_id, + line_code: "1250"}]` → `line_status == "ok"`, `line_code == "1250"`, `line_code_confidence == "100.00"`, + `line_code_modified is True`, `line_code_original_value is None` (БД), `row_index == 2`, `origin == "source"`, + `needs_review is False`; её сумма вошла в агрегат 1250 — Review Focus 3; + - `test_same_code_two_source_lines` — 1250 строке `unreadable` (`row_index 7`, без сумм), затем строке `missing` + (`row_index 2`) с суммой → обе `ok` с 1250, обе в БД, 409 нет — Review Focus 3; + - `test_code_taken_by_template_with_amount` — шаблонная `1250` (`row_index = null`) с суммой → `lines: [{unreadable, + line_code: "1250"}, {other, line_status: "detail"}]`, `values: [...]`, `header: {units: "руб."}` → 409 + `LINE_CODE_TAKEN` с `detail.line_id` строки `unreadable` и `detail.line_code == "1250"`; ни одна строка, ячейка и поле + шапки не изменились, аудита нет — Review Focus 4; + - `test_code_taken_by_empty_template` — шаблонная `1250` без сумм и запись `document_issue` с `line_ids = [template, + other]` → шаблонной строки и её значений нет, `line_ids == [other]`, строка источника `ok` 1250 — Review Focus 4; + - `test_mark_detail` — `missing` с суммой → `line_status == "detail"`, в агрегате её нет, подтверждение не даёт + `LINES_UNRESOLVED`; + - `test_status_ok_without_code` — `line_status: "ok"` → 422 `LINE_CODE_REQUIRED`, `detail.line_id == id`, + `detail.line_code is None`; + - `test_invalid_line_patch` — `{line_id}`; `{line_id, line_code, confirmed}`; один `line_id` дважды → 422 + `INVALID_LINE_PATCH`, `detail.line_id` — id элемента (у повтора — повторённый id), `detail.line_code` — код + элемента или `None`; + - `test_unknown_line` — строка другой формы → 422 `UNKNOWN_LINE`, `detail.line_id` — этот id; + - `test_invalid_line_code` — `"123"`, `"123456"`, `"12a4"` → 422 `INVALID_LINE_CODE`, `detail == {code, message, + line_id, line_code: "123"…}`; `"11101"` → 200; + - `test_confirm_code` — строка `ok` 1120, уверенность 50, `needs_review = true`, акцепт стоит → `confirmed: true` → + `is_confirmed is True`, `needs_review is False`, акцепт сохранён; `confirmed: false` → `is_confirmed is False` + (P3-16); + - `test_parent_moves_up` (сценарий «Родитель уходит в расшифровки») — `1140` (`row_index 3`), `1150` (`row_index 4`), + `detail` 5 и 6 с `parent_row_index = 4` → `1150` отмечена `detail` → у трёх строк `parent_row_index == 3`; без строк + `ok` выше — `None`; + - `test_line_edit_audit_and_accept` — одно событие, `before/after` с ключом `line:<id>` (`line_code`, `line_status`, + `parent_row_index`, `is_confirmed`); назначение кода сбрасывает акцепт; + - домен: `nearest_ok_parents` — `[ok r0, detail r1, ok(row None), detail r3]` → `{r1: 0, r3: 0}`. +- [ ] **Step 2:** запуск двух файлов — FAIL. +- [ ] **Step 3: Implement**; README «Ошибки API» — `LINE_CODE_TAKEN`, `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, + `INVALID_LINE_PATCH`, `UNKNOWN_LINE`. +- [ ] **Step 4:** запуск и `make check` — PASS. +- [ ] **Step 5: Commit** `feat(statements): PATCH lines — назначение кода, расшифровка, подтверждение кода, LINE_CODE_TAKEN`. + +### Завершение части А + +- [ ] `make check-full` (Docker: `make up`, затем `make migrate`) — PASS; вывод — в описание PR. +- [ ] Финальное ревью ветки (opus), таблица находок `## Findings` в ledger, PR `tea pulls create` со ссылкой на issue А + и arm-ka-docs#116; в описании — «Rulings I made», `P3-n` части А, O-1…O-10, «PR не выкладывается на контур без части Б». + +--- + +## Часть Б + +> Старт — после слияния PR части А и PR плана 2 (копия `api/arm_statements/`, `mypy src scripts arm_statements`, +> `statements-check` в `check-full`) в arm-core. Worktree части Б создаётся от `origin/main` с обоими слияниями. + +### Task B0: Change OpenSpec части Б в arm-core + +**Files:** +- Create: `openspec/changes/unified-normalization-core-b/` (`proposal.md`, `design.md`, `tasks.md`, спеки-дельты + `osv-result-mapping`, `documents-api`, `statement-checks` (REMOVED: «копия словаря §7 ядра»), `recognition-job-exchange`) + +- [ ] **Step 1:** `proposal.md` — требования спеки `arm-core` из колонки «Б» таблицы покрытия; `design.md` с разделом + «Decisions made without discussion» (P3-n части Б). +- [ ] **Step 2:** `openspec validate unified-normalization-core-b --strict` — без ошибок. +- [ ] **Step 3: Commit** `openspec(core-b): change единой нормализации, часть Б (arm-ka-docs#116)`. + +### Task B1: Адаптер пакета `StatementsEngine` и вход пересчёта из БД + +**Files:** +- Create: `api/src/services/statements_engine.py`, `api/src/domain/stored_form.py`, `api/tests/fake_engine.py` +- Test: `api/tests/test_domain_stored_form.py`, `api/tests/test_statements_engine.py` + +**Interfaces:** +- Consumes: копия `arm_statements` (имена — раздел «Публичный API пакета» плана 1; вызовы ядра — раздел «Что ядро вызывает»). +- Produces: + - `StatementsEngine` (протокол) и `default_engine()` — как в разделе «Что ядро вызывает»; `ArmStatementsEngine.version` + возвращает `statements_version()` (содержимое `api/arm_statements/VERSION`; нет файла — `None`), `recompute` / + `edition` / `code_order` / `editions` / `tolerance` берут `default_rules()` пакета, `contract_document_status` — + `document_status(status_input_from_contract(document))`; + - `build_stored_form(document: Document, meta: FinancialStatementMeta, columns: Sequence[FinancialStatementColumn], + lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], warnings: Sequence[DocumentIssue]) + -> StoredForm` (типы пакета, перечисления — `StrEnum`: `DocumentType`, `LineStatus`, `PeriodKind`, `Severity`; + уверенности — `float(...)`, `NULL` значения `is_recognized` — `False`; `period_kind = NULL` — `PeriodKind.NONE`): + `effective_date = document.reporting_date`; `has_organization_or_inn` — непустые + `meta.organization_name` или `meta.inn`; `period_known = document.reporting_date is not None`; + `needs_review_fields = tuple(meta.needs_review_fields or ())`; `accepted = meta.checks_accepted_at is not None`; + `warnings` — записи `kind = warning` (`code`, `severity`); `code_fixed = bool(line.line_code_modified) or + line.is_confirmed`; `value_fixed = value.is_modified or value.is_confirmed`; `text = value.source_text`; + `parent_key` — ключ строки с `row_index == line.parent_row_index` (у `detail`), иначе `None`; `key` строки и колонки — + `str(uuid)`; + - `edition_codes(doc: StatementDocumentV2) -> list[tuple[str, bool]]` — пары `(line_code, line_code_confidence == 100)` + строк `ok` результата по `row_index` (вход `engine.edition`); своего `StatusInput` ядро не собирает — вход статуса + из документа результата строит пакет (`status_input_from_contract`, план 1 P1-27); + - `FakeEngine` для модульных тестов: возвращает заданный `RecomputeResult`, заданные статус документа и редакцию, + запоминает вход `StoredForm`. + +- [ ] **Step 1: Write the failing tests:** `test_build_stored_form_maps_flags` (шаблонная строка → `row_index is None`; + правленая ячейка → `value_fixed`; подтверждённая ячейка → `value_fixed`; строка с назначенным кодом → `code_fixed`; + `detail` с `parent_row_index = 3` → `parent_key` строки `row_index 3`; `warnings` — только `kind = warning`); + `test_contract_document_status_matches_examples` (реальный пакет: у каждого `statementDocument` из + `contracts/examples/result-statement-minimal.json` и `result-statement-excel.json` — + `engine.contract_document_status(doc) == DocStatus(doc["status"])`; у минимального — `NEEDS_CHECK` из-за строки + `unreadable`); `test_edition_codes_and_engine_edition` (минимальный пример: пары в порядке `row_index`, строка + `unreadable` не входит; `engine.edition(DocumentType.BALANCE, pairs, date(2025, 9, 30), FormVariant.FULL)` — `Edition`; + тот же ответ, что `recompute` формы, собранной из этого результата); `test_engine_scenario_recompute_after_edit` (реальный пакет, + сценарий спеки «Пересчёт после правки по строкам БД»: ячейка 1230 `value_fixed`, код 1250 `code_fixed` у бывшей + `unreadable`, шаблонная 1260 с суммой, `detail` с уверенностью 60 → у 1230 и у значения `detail` `needs_review` `False`, + у строки 1250 `False`, соотношение `1200 = …` посчитано с 1250 и 1260; ожидаемый `RecomputeResult.checks` и + `value_needs_review` — с ключами `(str(line_id), str(column_id))`, `edition` — `Edition.FSBU4` или `RAS_66N` по дате); + `test_stored_form_types` (уверенность `Decimal("67.50")` в БД → `67.5` типа `float`; `is_recognized = NULL` → `False`; + `period_kind = NULL` → `PeriodKind.NONE`; `line_status` — `LineStatus`); `test_engine_version_reads_file` + (`engine.version == statements_version()`; `statements_version(tmp_path / "VERSION")` без файла — `None`). +- [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_domain_stored_form.py tests/test_statements_engine.py -v` — FAIL. +- [ ] **Step 3: Implement**; `ArmStatementsEngine` кэшируется на процесс (`functools.cache` у `default_engine`). +- [ ] **Step 4:** запуск и `make check` (в т. ч. `mypy src scripts arm_statements`) — PASS. +- [ ] **Step 5: Commit** `feat(statements): адаптер arm_statements за протоколом StatementsEngine и вход пересчёта из БД`. + +### Task B2: Пересчёт после правки общим кодом и §7 из `document_issue` + +**Files:** +- Create: `api/src/services/statement_recompute.py` +- Modify: `api/src/services/statements.py` (`recompute`, `checks_report`, `build_out`, `accept_checks`, + `statement_hard_failures`), `api/src/services/documents.py`, `api/src/repositories/statements.py` + (`replace_issues`), `api/src/schemas/statements.py` +- Test: `api/tests/test_api_statements.py`, `api/tests/test_api_statement_lines.py`, `api/tests/test_statement_recompute.py` + +**Interfaces:** +- Consumes: `StatementsEngine`, `build_stored_form` (B1), `name_mismatches`, `form_status_v2`, `has_error_warning`, + `duplicate_period_forms` (A10), `IssueDraft` (A9). +- Produces: + - `async def recompute_form(session, *, document, meta, engine: StatementsEngine, job: RecognitionJob | None) -> None`: + строит вход, вызывает `engine.recompute`, пишет `needs_review` строк и значений, `meta.form_variant`, + `parent_row_index` строк `detail` (ключ → `row_index`), заменяет записи `relation`/`soft` (любого источника) и записи + `core` по P3-8 (`RecomputeResult` пакета: `form_variant: FormVariant | None`, `edition: Edition | None`, + `checks`, `line_needs_review`, `value_needs_review`, `detail_parents`, `status: DocStatus`), пишет `STATEMENTS_VERSION_MISMATCH` (`info`, `core`), если `job.statements_version` не `None` и не равен + `engine.version`; статус (кроме `CONFIRMED`) — `form_status_v2(service_done = result.status == DocStatus.DONE, + file_error_warning = has_error_warning(job.metrics["warnings"]), counterparty, period_known = year и quarter, + names_ok = нет NAME_MISMATCH, duplicate_in_file)`; + - `relations_to_issues(checks: StatementChecks, *, columns, aggregates, source: IssueSource) -> list[IssueDraft]` — + `relations_failed` → `RELATION_FAILED`/`error`, `soft_relations_failed` → `RELATION_SOFT`/`info` (`FailedRelation` + плана 1); `column_id` — по `FailedRelation.column_key` (при пересчёте из БД это `str(column_id)`; при приёме `1.x` + колонки уже вставлены и ключ строится так же) — по `column_index` только для проверок сервиса `2.0` + (`column_key = str(column_index)`, B4); `line_ids` — все строки `ok` кодов `codes`; `expected`/`actual` — `Decimal | + None` (`None` — цель или все члены пусты, план 1 P1-21; в запись — `NULL`); `message` — `f"не сошлось {relation}: + {expected} ≠ {actual}"`, `None` пишется словом «пусто»; + - `StatementService.recompute` вызывает `recompute_form` (тело A12 удаляется, `nearest_ok_parents` A13 удаляется); + - `statement_hard_failures` и `checks.failed`/`soft_failed` ответа — из записей `relation`/`soft` (с `codes`, + `line_ids`); `NOTHING_TO_ACCEPT` — нет записей `relation`; правка `header.report_date` (в т. ч. `null`) пересчитывает + `period_date` колонок с `period_kind` `relative:*` через `engine.resolve_relative(PeriodKind(kind), effective_date)` до пересчёта. + +- [ ] **Step 1: Write the failing tests:** + - `test_1x_form_after_patch_recomputed` (сценарий «1.x-документ после PATCH») — форма `1.2` с несошедшимся `1200 = 1210 + + … + 1260` (запись `relation` по 1200 есть после приёма, B3; до B3 — вставить запись вручную) → правка 1230 так, что + сходится → записи `relation` по 1200 нет, статус пересчитан, `STATEMENTS_VERSION_MISMATCH` нет; + - `test_migrated_form_shows_violation_after_first_patch` (сценарий «Миграция на непустой базе») — форма без записей + `document_issue` с несошедшимся `1600 = 1700`: `GET` — `checks.failed == []`, `POST …/confirm` — без + `CHECKS_NOT_PASSED`; после любого `PATCH` — запись `RELATION_FAILED` с `codes` `["1600", "1700"]`; + - `test_unconfirm_uncovered_cell` (сценарий «Снятие подтверждения») — ячейка, не покрытая соотношением → `needs_review + is True` после пересчёта; + - `test_statements_version_mismatch` — `job.statements_version = "abc1234"`, `engine.version = "def5678"` → запись + `STATEMENTS_VERSION_MISMATCH` после `PATCH` (`FakeEngine.version` / реальный `statements_version()`); `job.statements_version = None` — нет; + - `test_relative_column_follows_report_date` — колонка `relative:prev_year_end`, `PATCH header.report_date = + 2025-09-30` → `period_date == 2024-12-31`; после ручной даты (A12) — не меняется; `report_date = null` → `period_date + is None` у относительных; + - `test_checks_from_issues_with_codes_and_line_ids` — `checks.failed[0].codes` и `line_ids` из записи; + - `test_one_patch_one_recompute` — `FakeEngine` вызван один раз за `PATCH` из трёх частей, событие одно. +- [ ] **Step 2:** запуск трёх файлов — FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4:** запуск и `make check` — PASS (тесты A12/A13 зелёные на общем коде). +- [ ] **Step 5: Commit** `feat(statements): пересчёт после правки точкой входа arm_statements, §7 только из document_issue`. + +### Task B3: Маппер `1.x` — §7 при приёме и вариант формы (Р-30) + +**Files:** +- Modify: `api/src/services/result_mapping.py` (`_insert_statements`), `api/src/services/statement_recompute.py` +- Test: `api/tests/test_mapping_statements.py`, `api/tests/test_api_statements.py` + +**Interfaces:** +- Consumes: `engine.recompute` (`RecomputeResult.form_variant`, `.checks`; `.status` здесь не используется), `build_stored_form`, `relations_to_issues` (B1, B2). +- Produces: `async def checks_on_intake_1x(session, *, document, meta, engine) -> bool` — один вызов + `engine.recompute` по вставленной форме; пишет `meta.form_variant` и записи `relation`/`soft` (`source = core`); возвращает + «жёстких нарушений нет» — условие D-6 «проверки сошлись»; `needs_review` значений и строк **не** перезаписывает (у `1.x` + они по правилу приёма, A9). + +- [ ] **Step 1: Write the failing tests:** `test_1x_balance_with_violation` (сценарий «Форма 1.x с нарушением»: `1.2`, ББ + с несошедшимся `1600 = 1700` → запись `relation` `RELATION_FAILED`, `codes == ["1600", "1700"]`, `source == "core"`, + документ `NEEDS_CHECK`; `POST …/confirm` без акцепта — 409 `CHECKS_NOT_PASSED`); `test_1x_simplified_variant` (ББ `1.2` + с 1600 и без 1100/1200 → `form_variant == "simplified"`); `test_1x_clean_income_recognized` (прежний + `test_clean_income_recognized` зелёный: `RECOGNIZED` и `CONFIRMED`). +- [ ] **Step 2:** запуск — FAIL. +- [ ] **Step 3: Implement**; `run_checks` ядра из `_insert_statements` убрать. +- [ ] **Step 4:** запуск, `make check`, `cd api && .venv/bin/pytest -m "integration and fixtures" tests/test_mapping_statements.py` — PASS. +- [ ] **Step 5: Commit** `feat(mapping): §7 и вариант формы при приёме 1.x копией arm_statements (Р-30)`. + +### Task B4: Маппер `2.0` — формы + +**Files:** +- Create: `api/src/domain/statement_mapping_v2.py`, `api/tests/factories_v2.py` +- Modify: `api/src/services/result_mapping.py` (`map_file`, выбор маппера), `api/src/repositories/statements.py` + (`insert_statement` — черновик v2) +- Test: `api/tests/test_domain_statement_mapping_v2.py`, `api/tests/test_mapping_statements_v2.py` + +**Interfaces:** +- Consumes: модели `result_v2` (A5), `IssueDraft`/`add_issues` (A9), `form_status_v2`, `duplicate_period_forms`, + `name_mismatches`, `has_error_warning` (A10), `engine.contract_document_status`, `engine.edition`, `engine.code_order`, + `edition_codes` (B1). +- Produces: + - `build_statement_v2(doc: StatementDocumentV2) -> StatementDraftV2` — строки как пришли (`row_index`, `source_ref = + ref`, `line_status`, `line_code`, `line_code_original_value = line_code`, `line_code_text`, `line_code_confidence`, + `line_name`, `line_name_confidence`, `parent_row_index` — `row_index` строки с `ref == parent_ref`, `needs_review`, + `is_confirmed = False`); значение на каждую пару «строка × колонка» (`amount`, `string_value`, `source_text = text`, + `confidence`, `is_recognized`, `needs_review`; нет в результате — пустая запись с `needs_review = False`); колонки с + `period_kind`, `label_confidence`; шапка: разобранные поля, `header_source` (`{поле: {"text": <поле>_text, + "confidence": <поле>_confidence}}` для 10 полей), `inn_original_value = inn`, `report_date_original_value = + report_date_text`, `units_original_value = units_text`, `organization_name_original_value = organization_name`, + `inn_confidence`, `needs_review_fields`, `form_variant`; P3-9; + - `service_issues(doc, *, line_ids_by_ref, column_ids_by_index, ok_line_ids_by_code) -> list[IssueDraft]` — + `doc.warnings` (`warning`, `service`; `line_refs` → `line_ids`; `column_index` → `column_id`, если колонка есть, иначе + `column_index`) и `doc.checks` (`relation`/`soft`, `service`; `column_id` — по `column_index` колонки: у сервиса `FailedRelation.column_key = str(column_index)`); + - `core_info_issues(*, lines, edition: Edition | None, form_variant: FormVariant | None, catalog, engine) -> list[IssueDraft]` — `CODE_NOT_IN_DICTIONARY`, + `CODE_NOT_IN_EDITION` (P3-7), `NAME_MISMATCH` (строка); `edition is None` или `form_variant is None` — проверки по редакции не пишутся; + `edition = engine.edition(DocumentType(doc.document_type), edition_codes(doc), document.reporting_date, + FormVariant(doc.form_variant) if doc.form_variant else None)` — без сборки `StoredForm` и пересчёта; + - статус формы: `service_done = doc.status == DocStatus.DONE and engine.contract_document_status(doc.model_dump(mode="json")) == DocStatus.DONE`; + `expected`/`actual` нарушений результата — `Decimal | None` (контракт: `null` — цели нет); + `doc.status == "done"` при несогласии — запись `RESULT_STATUS_INCONSISTENT` (`info`, `core`, у документа); + `file_error_warning = has_error_warning(result.metrics.warnings)` (P3-15); `RECOGNIZED` → автоподтверждение (как сейчас); + - `job.statements_version = result.service.statements_version`; `DISCLOSURE` — один документ на файл (P3-6), + предупреждения всех объединённых — в его `document_issue`. + +- [ ] **Step 1: Write the failing tests** (`factories_v2.statement_result_v2(...)` собирает результат `2.0` из базового + словаря с переопределениями; реальный пакет): + - golden на примерах docs: `result-statement-minimal.json` — строка `unreadable` с `line_code is None`, + `line_code_text` как в примере, документ `NEEDS_CHECK`; `result-statement-excel.json` — `BALANCE` с записью + `RELATION_SOFT`, `INCOME` со строкой `detail` и `parent_row_index` строки-родителя, один `DISCLOSURE` без строк; + - `test_repeated_code_kept` — две строки `ok` 2410 (пустая и `(649)`) → две строки с разными `row_index` и `source_ref`, + у каждой свои значения; + - `test_line_without_code` — `unreadable`, `line_code_text = "ИЧ,250"`, сумма → `line_code is None`, + `line_code_original_value is None`, `line_status == "unreadable"`, `row_index` из результата; + - `test_two_balances_same_period` — два `BALANCE` 2025 Q3 → разные `file_ordinal`, оба `NEEDS_CHECK`, ни один не + `CONFIRMED` — Review Focus 1; `test_two_balances_other_periods` — каждый по своим условиям; + - `test_neighbor_needs_check` — ББ `done`, ОФР `needs_check`, ошибок файла нет, ИНН совпал, 30.09.2025 → ББ `CONFIRMED` + (автоподтверждён), ОФР `NEEDS_CHECK`; + - `test_json_failed_blocks_all` — все `done`, `metrics.warnings` с `JSON_FAILED` (`error`) → все формы `NEEDS_CHECK`; + то же для `TABLE_PARSE_ERROR`; + - `test_name_mismatch_neighbor_code` — 1120 (50) «Нематериальные активы» → `NEEDS_CHECK`, `NAME_MISMATCH` у строки; + `test_legal_repeat_name` — 1220 (50) «НДС, начисленный по отгрузке» → `NAME_MISMATCH` нет; `test_section_total_swap` + — 1200 (50) «Итого по разделу I» → `NAME_MISMATCH`; `test_section_ocr_tail` — 1500 (56) «Итого по разделу V1» → нет; + - `test_done_with_unreadable_without_values` и `test_done_with_unreadable_with_amount` — `NEEDS_CHECK`, + `RESULT_STATUS_INCONSISTENT` у документа, не `CONFIRMED`; + - `test_report_date_not_quarter_end` — 15.08.2025 → `NEEDS_CHECK`; `POST …/confirm` → 409 `PERIOD_REQUIRED`; + - `test_66n_blank_in_2025` — Межениновская 3 кв. 2025 на бланке 66н с 1120 → `CODE_NOT_IN_EDITION` у 1120 нет; + - `test_column_skipped_issue` — `COLUMN_SKIPPED`, `column_index = 5`, колонки 5 нет → запись `column_index == 5`, + `column_id is None`; + - `test_header_source_hint` — `organization_name_text = "ООО «Ромашкa»"`, `organization_name_confidence = 67` → + `header_source["organization_name"] == {"text": "ООО «Ромашкa»", "confidence": 67}` и то же в `GET` + (`header.source`); + - `test_statements_version_and_ordinals` — `service.statements_version = "abc1234"` → у задания `"abc1234"`; `BALANCE`, + `INCOME` → `file_ordinal` 0, 1. +- [ ] **Step 2:** запуск двух файлов — FAIL. +- [ ] **Step 3: Implement**; `map_file` получает `RecognitionResult | RecognitionResultV2` и выбирает маппер по типу. +- [ ] **Step 4:** запуск и `make check` — PASS. +- [ ] **Step 5: Commit** `feat(mapping): маппер результата 2.0 для форм — хранение как пришло, document_issue, условие RECOGNIZED`. + +### Task B5: Маппер `2.0` — ОСВ, выбор по MAJOR, приём файла результата + +**Files:** +- Modify: `api/src/services/result_mapping.py` (`_load_result`, ОСВ-часть `map_file`), `api/src/domain/osv_mapping.py` + (статус по Р-28) +- Test: `api/tests/test_result_mapping.py`, `api/tests/test_mapping_statements_v2.py`, `api/tests/test_api_documents.py` + +**Interfaces:** +- Consumes: `parse_result` (A5), `osv_status_v2`, `has_error_warning` (A10), `IssueDraft` (A9). +- Produces: `_load_result` → `versions.parse_result` (MAJOR 1 — маппер `1.x`, MAJOR 2 — маппер `2.0`, иное — + `MappingFatal` с итогом проверки схемы в `metrics.mapping_error`, файл `ERROR`, без повтора); документ ОСВ `2.0` — + статус `osv_status_v2(osv_done = sheet.status == "done", checks_ok, file_error_warning, counterparty, + sheet_mapping_warnings)`; `sheet.warnings` → `document_issue` (`warning`, `service`, коды `OSV_*`, `info`). + +- [ ] **Step 1: Write the failing tests:** `test_osv_in_book_with_needs_check_income` (сценарий спеки: ОСВ `done` с + сошедшимися контрольными суммами + ОФР `needs_check`, конверт `needs_check`, ошибок файла нет → ОСВ `CONFIRMED`, её + `OSV_*` — в `DocumentOut.issues`); `test_osv_v2_examples` (`result-osv-minimal.json`, `result-osv-needs-check.json` — + статусы по примерам); `test_two_osv_v2_ordinals` (ОСВ 60, 62 → 0, 1); `test_result_major_3_is_fatal` (`schema_version + "3.0"` → файл `ERROR`, `metrics.mapping_error` начинается с «результат не соответствует схеме»); + `test_result_1x_still_mapped` (`1.2` — маппер `1.x`). +- [ ] **Step 2:** запуск — FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4:** запуск, `make check`, `cd api && .venv/bin/pytest -m "integration and fixtures" tests/test_mapping_golden.py tests/test_mapping_statements.py` — PASS. +- [ ] **Step 5: Commit** `feat(mapping): ОСВ по контракту 2.0 (Р-28) и выбор маппера по MAJOR`. + +### Task B6: Удаление копии словаря §7 ядра, редакции из перечня пакета + +**Files:** +- Delete: `api/rules/statement_checks.yaml` +- Modify: `api/src/domain/statement_checks.py` (остаются `cross_form_check` и `subline_parents`; `RelationRule`, + `CheckRules`, `load_check_rules`, `run_checks`, `FailedCheck.codes` удаляются), `api/src/services/statements.py` + (`checks_report`: допуск — `engine.tolerance()` = `default_rules().checks.tolerance`), `api/src/domain/statement_lines.py` (порядок и состав редакций — + `engine.editions()` = `default_rules().code_orders.editions` (`Edition`)), `CLAUDE.md` (раздел «Формы ББ/ОФР»: §7 — копия `arm_statements`, проверки хранятся в + `document_issue`), `README.md` +- Test: `api/tests/test_domain_statement_checks.py`, `api/tests/test_domain_statement_lines.py` + +**Interfaces:** +- Consumes: `engine.tolerance()`, `engine.editions()`, `engine.code_order(edition, document_type, form_variant)` (B1; `Rules.code_orders.sequence` плана 1). +- Produces: `cross_form_check(balance_1370, income_2400, tolerance: Decimal) -> FailedCheck | None` без изменений + сигнатуры; `test_name_dictionary_codes_in_code_orders` — каждый код `statement_lines.yaml` редакции есть в перечне + этой редакции копии пакета (любой вариант формы). + +- [ ] **Step 1: Write the failing tests:** `test_name_dictionary_codes_in_code_orders`; `test_no_core_check_rules` + (`not (API_DIR / "rules" / "statement_checks.yaml").exists()` и в `src/` нет `load_check_rules`); тесты + `run_checks` в `test_domain_statement_checks.py` удаляются, тесты `cross_form_check` и `subline_parents` остаются. +- [ ] **Step 2:** запуск — FAIL. +- [ ] **Step 3: Implement.** +- [ ] **Step 4:** `make check` — PASS; `grep -rn "statement_checks.yaml\|run_checks\|load_check_rules" api/src api/tests` — + пусто. +- [ ] **Step 5: Commit** `refactor(statements): удалена копия словаря §7 ядра — проверки копией arm_statements`. + +### Завершение части Б + +- [ ] `make check-full` — PASS, `statements-check` — код `0` или `10` (предупреждение «копия отстала» — в описание PR). +- [ ] Финальное ревью ветки (opus), `## Findings` в ledger, PR со ссылкой на issue Б и arm-ka-docs#116; в описании — + «Rulings I made», `P3-n` части Б, открытые `O-n`, напоминание о порядке выкладки (очистка → миграция → ядро А+Б → фронт; + сервисы на `2.0` — после). + +--- + +## Покрытие спеки `arm-core` + +| Требование спеки | Часть А | Часть Б | +|---|---|---| +| Хранение результата формы v2.0 (DBML v5.19) | схема A1; `file_ordinal` и Р-10 у `1.x` A9 | маппер `2.0` B4, B5 | +| Признак строки, созданной правкой | A7, A13 | — | +| Шапка формы — сырой текст и уверенность | схема A1; `1.x` A9; `header.source` A8 | `2.0` B4 | +| Агрегат формы по коду | A7 | — | +| Условие `RECOGNIZED` при приёме 2.0 | чистые функции A10; словарь A6 | B4 (страховка — `engine.contract_document_status`) | +| Предупреждения и нарушения документа — `document_issue` | таблица A1; снятие правкой A12; `line_ids` A13; `issues` A8 | `relation`/`soft`, `core` B2–B4 | +| Формы результата 1.x — §7 при приёме | поля и `MAPPING_WARNING` A9; `LEGACY_WARNING` A5 | B3 | +| Пересчёт после правки | временный пересчёт A12 | B2 | +| PATCH формы — строки, колонки, подтверждение, наименование | A12, A13 | относительные даты B2 | +| Подтверждение формы — порядок 409 | A11 | `CHECKS_NOT_PASSED` из `document_issue` B2 | +| Словари ядра и достройка шаблонных строк | словарь имён A6; достройка A7 | удаление копии §7, редакции из перечня B6 | +| Ответ `GET …/statement` по v2.0 | A8 | `checks` из `document_issue` B2 | +| Миграция DBML v5.19 | A1, A2 | сценарий «после PATCH … `RELATION_FAILED`» — B2 | +| Очистка форм перед выкладкой | A4 | — | +| Предупреждения файла и ОСВ в API, статус ОСВ при приёме 2.0 | A5, A8, A9 | B5 | +| `crm_id` ГСК/ГСЗ | A3 | — | +| `recognition-contract`: «сначала ядро», ack 2.0, копии обоих MAJOR | A5 | B5 | diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md new file mode 100644 index 0000000..8345fcf --- /dev/null +++ b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-4-tabular-parser.md @@ -0,0 +1,1133 @@ +# План 4 — tabular-parser: адаптер Excel шага 1, общий код `arm_statements`, контракт v2.0. План реализации + +> **Для исполнителей-агентов:** обязательный навык — superpowers:subagent-driven-development (в плане 10 задач: по +> `~/.claude/CLAUDE.md` — SDD, контроллер — фоновый сабагент на модели sonnet; модели исполнителей указаны в каждой +> задаче; финальное ревью ветки — opus). Шаги размечены чекбоксами (`- [ ]`). + +**Goal:** tabular-parser разбирает формы ББ/ОФР из Excel так: свой код читает книгу, классифицирует листы, выделяет блоки +форм и склеивает листы-продолжения в `FormCandidate` (адаптер шага 1), а шаги 2–8 выполняет копия пакета +`arm_statements`; результат и конверт — контракт v2.0 (у ОСВ — `status` и структурные предупреждения `OSV_*`); собственный +код шагов 2–8 форм и словари форм удалены; манифест реальных форм и golden обновлены. + +**Architecture:** новые модули адаптера пишутся рядом со старыми (`statements/cells.py`, `forms.py`, `header_cells.py`, +`adapter.py`, `pipeline.py`); переключение книги на пакет и на контракт v2.0 — одной задачей (модели, +копия схем, сборка книги, путь ОСВ, воркер), сразу за ней — golden и манифест, затем удаление старого кода форм. Пакет +вызывается только через публичный API (`from arm_statements import …`); ячейка Excel → `Cell` (текст по числовому формату, +`value`, `ref = <лист>!<A1>`) — в адаптере; разбор ОСВ не меняется, меняется только вид его предупреждений и статус листа. + +**Tech Stack:** Python 3.11, pydantic v2, openpyxl / xlrd / pyxlsb (читатели как есть), PyYAML, pytest, ruff, mypy; +копия пакета `api/arm_statements/` (stdlib + PyYAML); `tea` (Gitea), OpenSpec, codegraph. + +**Spec:** `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/` +(ветка `feat/116-unified-normalization` репозитория arm-ka-docs): главное — `specs/tabular-recognition-service/spec.md`; +шаг 1 для Excel — `specs/statement-normalization/spec.md` («Шаг 1 — кандидат в форму», «Шаг 1 — шапка кандидата», «Шаг 1 — +многостраничная форма», «Шаг 8 — предупреждения», «Шаг 8 — статус документа и конверта», «Шаг 4 — единицы и дробные +значения»); контракт — `specs/recognition-contract/spec.md` и `contracts/*.schema.json` v2.0, `contracts/examples/*.json`; +правила — `context/05-domain-rules.md` §1.6, §11.2, §11.9; порядок выкладки — `proposal.md` (сервисы переходят на 2.0 +после ядра, шаг 5). API пакета — раздел «Публичный API пакета» плана 1 (`plans/2026-10-18-plan-1-arm-statements.md`): +только эти имена. `design.md` — справка (Р-n, S-n); спека важнее. + +--- + +## Место в работе и условия старта + +| План | Что | Связь с этим планом | +|---|---|---| +| 0 | тест идентичности копий контракта по MAJOR (в т. ч. tabular-parser) | слит в tabular-parser **до** задачи 7: без него копия схем v2.0 не сверяется с docs | +| 1 | пакет `arm_statements` (репозиторий `arm-statements`) | API — источник имён этого плана; копия пакета приходит в tabular-parser через план 2 | +| 2 | `sync-statements`: копия в `api/arm_statements/`, подготовка хоста (его задача 8) | PR задачи 8 плана 2 в tabular-parser слит **до старта**; задача 2 этого плана только сверяет и при нужде обновляет копию `--in-place` | +| 3 | arm-core части А и Б: приём 2.0 | PR этого плана **сливается** только после выкладки ядра, принимающего 2.0 (`proposal.md`, шаг 5) | +| docs PR #117 | контракт v2.0, правила | до слияния — отсоединённый worktree docs на `origin/feat/116-unified-normalization`; после — на `origin/main` | + +Разработка этого плана может идти параллельно с планом 3; **слияние** PR tabular-parser и PR fixtures — только по команде +человека после выкладки ядра (иначе первый же результат 2.0 даст файлу `ERROR` у ядра 1.x). + +## Предусловия (для контроллера, до задачи 1) + +- **П-1.** Issue в RSHB/arm-tabular-parser: `tea issues create --login home --repo RSHB/arm-tabular-parser --title + "Формы ББ/ОФР через общий код arm_statements, контракт результата v2.0 (arm-ka-docs#116)" --description "<цель, ссылка на + этот план>"`. Issue в RSHB/arm-ka-fixtures (golden этого PR): `--title "Golden форм и ОСВ под контракт v2.0 + (arm-tabular-parser#<issue>)"`. +- **П-2.** Зеркальный workspace `$WS/.wt/normalization-tp/` (`$WS` = `/Users/andrey.epifancev/projects/awp-ca`; + `00-workspace-layout.md`, «Worktree вместо веток»): worktree `tabular-parser/` (ветка фичи), worktree `fixtures/` + (ветка фичи fixtures — golden пишется сюда, поэтому **не** симлинк), отсоединённый worktree `docs/` (в нём не + коммитится), симлинки `reference-old-arm`, `CLAUDE.md`, `.claude` (источник `arm-statements` скрипт плана 2 находит + сам — `$WS/arm-statements`): + +```bash +WS=/Users/andrey.epifancev/projects/awp-ca; M=$WS/.wt/normalization-tp +mkdir -p $M +git -C $WS/tabular-parser fetch origin +git -C $WS/tabular-parser worktree add -b tmp/statements-v2 $M/tabular-parser origin/main +git -C $WS/fixtures fetch origin +git -C $WS/fixtures worktree add -b tmp/statements-v2-golden $M/fixtures origin/main +git -C $WS/docs fetch origin +git -C $WS/docs worktree add --detach $M/docs origin/feat/116-unified-normalization +for l in reference-old-arm CLAUDE.md .claude; do ln -s $WS/$l $M/$l; done +git -C $M/tabular-parser branch -m feat/<issue>-statements-v2 +git -C $M/fixtures branch -m feat/<fx-issue>-statements-v2-golden +cd $M/tabular-parser && codegraph init && make venv +``` + +- **П-3.** В тексте плана `$T` = `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-tp/tabular-parser`, + `$F` = `…/normalization-tp/fixtures`, `$D` = `…/normalization-tp/docs`. Команды `make` — из `$T`, backend — из `$T/api`, + git — `git -C <путь>`. Перед правкой исполнитель вызывает `codegraph_explore` с `projectPath` = `$T`. +- **П-4.** Проверить: в `$T` есть `api/arm_statements/__init__.py` и `api/arm_statements/VERSION` (PR задачи 8 плана 2 + слит) и `api/tests/contract_identity.py` (план 0). Нет копии пакета — стоп и вопрос владельцу (подключение копии — + PR плана 2, не этого плана; руками копию не раскладывать). Нет плана 0 — задачи 1–6 можно вести, задача 7 ждёт. +- **П-5.** После слияния docs PR #117: `git -C $D checkout --detach origin/main`. + +## Global Constraints + +- Пакет — только через публичный API `from arm_statements import …` (имена раздела «Публичный API пакета» плана 1; в т. ч. + `Rules`, `default_rules`, `Cell`, `Evidence`, `HeaderCells`, `FormCandidate`, `Source`, `DocumentType`, + `StatementWarning`, `WarningCode`, `normalize_candidate`, `envelope_status`, `make_warning`, `type_hint`, + `is_continuation`, `continuation_type`, `sheet_joins`, `code_column`, `amount_columns`, `normalize_label`, + `normalize_caption`, `parse_code`, `parse_date`, `parse_header`, `to_contract`, `warning_to_contract`, `to_jsonable`, + `statements_version`). Импорт внутренних модулей пакета (`arm_statements.codes` и т. п.) в `src/` запрещён. Файлы + `api/arm_statements/**` не правятся (прямая правка — код 20 `statements-check`); нехватка в API — находка `O-n`, не + правка копии. Правило шага 2 (столбец кодов, колонки сумм) и нормализация меток в адаптере не повторяются — только + функции пакета (P4-6, P4-7). +- Шаг 1 для Excel (спека): `source = excel`; `locator = sheet:<A>[,<B>…]`; у ячейки `confidence = 100`, `text` — как + показывает числовой формат, `value` — число по формату (`Decimal`), дата или `bool`; `ref = <лист>!<A1>`, имя листа в + одинарных кавычках, если в нём есть что-то кроме букв, цифр и `_` или оно начинается с цифры, апостроф внутри имени + удваивается; одна форма — одна сетка, граница листа — номер строки сетки в `segment_breaks`. +- Координат в `src/parser/**` нет (кроме `grid.py`, `readers/`) — AST-линтер `tests/unit/parser/test_no_coordinates.py`; + буквы колонок и адрес ячейки строит только `grid.py`. Слов форм в коде нет — только словари пакета (`Rules.headers.*`). +- Контракт: файл результата и конверт (включая ack `processing`) — `schema_version = "2.0"`; запрос остаётся `1.0` + (поддерживаемый MAJOR запроса — `"1"`, `SCHEMA_MISMATCH` по-прежнему). `service.statements_version` — содержимое + `api/arm_statements/VERSION`, обязателен при `statementDocument`. +- Предупреждения форм и файла — только через `make_warning` с членом `WarningCode` (код и уровень — словарь пакета); у + ОСВ — код с префиксом `OSV_`, уровень `info`. Строк вида `sheet=<имя> <текст>` в `metrics.warnings` нет. Перевод + документов форм и предупреждений в контракт — только `to_contract` / `warning_to_contract` пакета. +- Статус конверта — `envelope_status` пакета по статусам всех документов (ОСВ — `done`/`needs_check` листа) и + предупреждениям файла; документов нет — `error INVALID_DOCUMENT`. Порядок документов: ОСВ в порядке листов, затем формы. +- Разбор ОСВ не меняется (`classify.py`, `to_money`, `hierarchy.py`, `checks.py` по существу, словари §1): golden ОСВ + после перехода отличается только ключами `schema_version`, `metrics.warnings`, `documents[].status`, + `documents[].warnings`. +- Деньги — `Decimal`, без `float`; первичка не мутируется. +- Golden правится только `make golden-update` с просмотром diff и отдельным коммитом в fixtures. +- `make check` не требует Docker и сети; `statements-check` — только в `make check-full`. +- Язык комментариев, docstring, сообщений, коммитов, issue и PR — русский. Коммиты заканчиваются строками + `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>` и + `Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W`; PR — строкой + `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. Слияние — только по команде человека. +- Находки и решения несут маркеры (`T<n>.<k>`, `C-n`/`I-n`/`M-n`, `R-n`, `O-n`); решения этого плана — `P4-n` (раздел в + конце), контроллер переносит их в `design.md` docs (`## Decisions made without discussion`). + +## Review Focus + +Реальные книги, где адаптер шага 1 встречает то, что сценарии спеки описывают по одному. Каждый пункт закреплён тестом +задачи-владельца: + +1. **Титульный блок «Коды» выгрузки 1С** (Унагранде «Б.Б Лист 1», «ББ. Лист 1 .xls», «ББ. Лист 1 3кв.25», «ОФР. Лист 1» ×2): + справа «Коды» с ОКУД, датой, ОКПО, ИНН, ОКОПФ 12267, ОКФС, ОКЕИ. Ожидание: столбец под «Коды» не столбец кодов, шапка + берёт `okopf = "12267"`, строки с кодом или текстом кода «12267» нет, ББ `needs_check` только из-за `1600 = 1700`, ОФР + `done`. Тесты: `test_code_column_ignores_title_codes_block` (задача 4), `test_unagrande_title_codes_block` (задача 8). +2. **НПЗ — листы разной геометрии**: ББ на 5 листах, у «Лист 4» своя строка заголовка и колонки в других столбцах; ОФР на 3 + листах, последний — одна строка 2400 с кодом в другой колонке. Ожидание: два кандидата с 5 и 3 листами, `segment_breaks` + на каждой границе, значения листов-продолжений под `column_index` первого листа, нет `SHEET_NOT_JOINED`, + `SEGMENT_*`, `HEADER_ROW_NOT_FOUND`; строка «Реализация иного имущества…» — `detail` с `parent_ref` строки 2340; оба + `done`. Тесты: `test_npz_candidates` (задача 5), `test_npz_documents` (задача 8). +3. **Межениновская — нумерация и подписи под формой**: строка «1 | 2 | 3 | 4 | 5» с «2» в столбце кодов, ниже таблицы — + подписи руководителя и дата. Ожидание: строки с `line_code_text = "2"` нет, строк `unreadable` / `missing` нет, ИНН + `7016006635` из шапки, ББ и ОФР `done`. Тест: `test_mezheninovskaya_numbering_and_signatures` (задача 8). Если тест + падает на стороне шагов 2–3 (роли строк) — это находка для пакета (план 1), а не повод править сетку в адаптере. +4. **xlsb без числовых форматов**: у каждой формы книги xlsb — `XLSB_NO_FORMATS` (`info`); при «тыс. руб.» и ≥ 10 % + дробных значений — `XLSB_SCALE_SUSPECT` (`error`), документ `needs_check`; без дробных — `done`. Тест: + `test_xlsb_form_scale_suspect` (задача 7, синтетика `write_xlsb`). +5. **«Пояснения»**: книга только из пояснений (лист с ОКУД 0710003 и лист «Пояснения к бухгалтерскому балансу» без кодов) + — документы `DISCLOSURE` `done`, конверт `done`; лист «Пояснения к бухгалтерскому балансу» с кодами 1110…1700 — + `BALANCE` и `TYPE_CONFLICT` (`error`). Тесты: `test_disclosures_only_book_is_done`, + `test_disclosure_title_with_balance_codes` (задача 7). + +--- + +## Структура изменений + +``` +tabular-parser/ +├── openspec/changes/unified-normalization-tp/ # задача 1: proposal, tasks, дельты трёх спек +├── api/arm_statements/** # копия пакета (план 2); задача 2 — только --in-place при нужде +├── api/contracts/** # задача 7: копия docs/contracts v2.0 (без .v1 и examples/v1) +├── api/src/contract/{common,request,result,__init__}.py # задача 7: модели v2.0 +├── api/src/parser/ +│ ├── grid.py # задача 3: cell_ref() +│ ├── osv_warnings.py # задача 6: OsvNote и коды OSV_* +│ ├── checks.py, osv.py # задача 6: заметки с кодами; задача 7: status/warnings в документе +│ ├── detect.py # задача 5: is_osv_sheet(); задача 7: classify_sheet на словарях пакета +│ ├── readers/__init__.py # задача 7: BookRead.mismatch +│ ├── book.py, __init__.py # задача 7: сборка книги через пакет, require_statements_version() +│ └── statements/ +│ ├── number_format.py # задача 3: текст по формату (остаётся) +│ ├── cells.py # задача 3: ячейка Excel → Cell, сетка +│ ├── forms.py # задача 4: блоки форм, вид листа, столбец кодов, колонки сумм +│ ├── header_cells.py # задача 4: шапка блока → HeaderCells, Evidence +│ ├── adapter.py # задача 5: план листов книги, склейка → FormCandidate +│ ├── pipeline.py # задача 7: StatementPipeline (адаптер → normalize_candidate → контракт) +│ └── {checks,columns,detect,header,lines,merge,statement}.py # задача 9: удаляются +├── api/rules/statement_{headers,periods,checks}.yaml # задача 9: удаляются (словари в пакете) +├── api/src/core/worker.py, api/src/app.py, api/src/parser/cli.py # задача 7: statements_version, версии +└── api/tests/… # по задачам; statements_manifest.yaml — задача 8 +fixtures/golden/expected_{osv,statement}_parse/** # задача 8 (отдельный PR fixtures) +``` + +--- + +### Task 1: OpenSpec change `unified-normalization-tp` в tabular-parser + +Модель исполнителя: sonnet (перенос требований спеки в дельты). + +**Files:** +- Create: `$T/openspec/changes/unified-normalization-tp/proposal.md`, `tasks.md`, + `specs/statement-parsing/spec.md`, `specs/tabular-recognition-service/spec.md`, `specs/osv-parsing/spec.md` + +**Interfaces:** +- Consumes: текущие `$T/openspec/specs/{statement-parsing,tabular-recognition-service,osv-parsing}/spec.md`; спека docs + (`specs/tabular-recognition-service/spec.md`, требования шага 1 Excel в `specs/statement-normalization/spec.md`). +- Produces: change, на который ссылаются коммиты и PR; при закрытии — `openspec archive unified-normalization-tp`. + +Состав дельт (текст требований — из спеки docs, приведённый к языку спек сервиса; сценарии — дословно из docs): +- `statement-parsing`: **MODIFIED** «Листы форм и их продолжения находятся по содержимому» (классификация: ОСВ → форма → + продолжение → прочее, слабое продолжение только сразу после листа формы, `SHEET_OTHER`), «Шапка документа» (поля как + `Cell` с `confidence = 100` в границах блока, разбор — общим кодом), «Склейка листов-продолжений и статус документа» + (до раскладки, `segment_breaks`, `CONTINUATION_TYPE_CONFLICT`, `SHEET_NOT_JOINED`; статус — функцией пакета), «Словари + форм, координаты, golden» (словари форм — в пакете; координат нет; golden и манифест); **REMOVED** «Заголовок таблицы, + колонка кодов и колонки-периоды определяются по тексту», «Строки с кодом и значения», «Соотношения §7 — данные, + проверка по колонкам» (Reason: шаги 2–8 выполняет копия `arm_statements`; Migration: требования — + `statement-normalization` docs); **ADDED** «Адаптер Excel шага 1», «Ячейка Excel», «Строки без кода и неизвестные + колонки в Excel», «Манифест ожиданий на реальных формах», «Статус книги» (из docs `tabular-recognition-service`). +- `tabular-recognition-service`: **MODIFIED** «Результат по контракту» (v2.0, `service.statements_version`, структурные + `metrics.warnings`, ack `processing` — 2.0, запрос 1.0). +- `osv-parsing`: **MODIFIED** «Контрольные суммы определяют статус» (`status` листа и `warnings[]` `OSV_*` `info` в + документе, строк `sheet=` нет) и «Форматы, лимиты, время» (`FILE_TYPE_MISMATCH` — предупреждение файла `info`). + +- [ ] **Step 1: Прочитать три текущие спеки сервиса и требования docs; написать proposal.md** (Why — ссылка на docs + change и этот план; What Changes — по списку выше; Impact — модули из «Структуры изменений»). +- [ ] **Step 2: Написать дельты** — MODIFIED с полным новым текстом требования и всеми его сценариями. +- [ ] **Step 3: `tasks.md`** — задачи 1–10 этого плана одной строкой каждая. +- [ ] **Step 4: Проверка** + +Run: `cd $T && openspec validate unified-normalization-tp --strict` +Expected: `Change 'unified-normalization-tp' is valid` + +- [ ] **Step 5: Commit** + +```bash +git -C $T add openspec/changes/unified-normalization-tp +git -C $T commit -m "openspec: change unified-normalization-tp — адаптер Excel, общий код, контракт v2.0 (#<issue>)" +``` + +--- + +### Task 2: Подготовка хоста — сверка с планом 2 и свежесть копии + +Модель исполнителя: haiku (проверки по списку). + +Подготовку хоста делает план 2 (`plans/2026-10-18-plan-2-sync-statements.md`, задача 8 «tabular-parser — подключение +копии»; раздел «Что должны включить планы 3, 4, 5»), и её PR слит до старта (П-4). Эта задача только сверяет `main` +с планом 2 и, если пакету нужна версия новее, обновляет копию скриптом. Недостающее из списка ниже — **не** доделывать +здесь, а остановиться и сообщить контроллеру (находка `O-n`: правка подключения — PR плана 2). + +**Files:** +- Modify (только если нужна новая версия пакета): `$T/api/arm_statements/**` — скриптом, руками не править + +**Interfaces:** +- Consumes (из плана 2): `make lint` → `mypy src arm_statements`; `api/pytest.ini` `testpaths` с `arm_statements/tests`; + `known-first-party = ["src", "arm_statements"]`; строгий блок `[[tool.mypy.overrides]]` для `arm_statements.*`; + пины `ruff==0.16.10`, `mypy==2.4.0`, `types-PyYAML==6.0.12.20260906`; цель `statements-check` в `check-full`, не в + `check`; `.dockerignore` и `Dockerfile` не менялись (словари копии попадают в образ через `COPY api/ ./`). +- Produces: копия, в API которой есть все имена раздела Global Constraints. + +- [ ] **Step 1: Сверка подключения** — каждый пункт Consumes есть в `$T` (`grep` по `Makefile`, `api/pytest.ini`, + `api/pyproject.toml`, `api/requirements-dev.txt`); `api/arm_statements/VERSION` — 40 hex-символов. +- [ ] **Step 2: Свежесть и API копии** + +Run: `cd $T && make statements-check; cd api && .venv/bin/python -c "import arm_statements as a; print(a.statements_version()); [getattr(a, n) for n in ('Rules','default_rules','Cell','Evidence','HeaderCells','FormCandidate','Source','DocumentType','StatementWarning','WarningCode','normalize_candidate','envelope_status','make_warning','type_hint','is_continuation','continuation_type','sheet_joins','code_column','amount_columns','normalize_label','normalize_caption','parse_code','parse_date','parse_amount','parse_header','to_contract','warning_to_contract','to_jsonable','statements_version')]"` +Expected: `statements-check` — `0` или `10`; хэш печатается, `AttributeError` нет. + +- [ ] **Step 3 (только при `10` «копия отстала» или нехватке имени):** обновить копию в worktree фичи + +Run: `cd $T && ../docs/scripts/sync-statements.sh --in-place --host-dir . && make check` +Expected: `statements-check tabular-parser: 0`; `make check` зелёный. + +```bash +git -C $T add api/arm_statements +git -C $T commit -m "chore(arm_statements): копия пакета arm-statements@<v7> (#<issue>)" +``` + +- [ ] **Step 4: Базовая проверка** — `cd $T && make check` зелёный, в выводе pytest есть `arm_statements/tests/…`. + +--- + +### Task 3: Ячейка Excel → `Cell`: ссылка, текст по формату, сетка + +Модель исполнителя: sonnet. + +**Files:** +- Modify: `$T/api/src/parser/grid.py` (`cell_ref`), `$T/api/src/parser/statements/number_format.py` (текст) +- Create: `$T/api/src/parser/statements/cells.py` +- Test: `$T/api/tests/unit/parser/test_grid.py`, `$T/api/tests/unit/parser/statements/test_number_format.py`, + `$T/api/tests/unit/parser/statements/test_cells.py` + +**Interfaces:** +- Consumes: `Sheet`, `Cell` (grid), `display_amount`, `arm_statements.Cell`. +- Produces: + - `grid.cell_ref(sheet_name: str, row: int, col: int) -> str` — 1-based; правило кавычек — Global Constraints + («буква» — `str.isalpha()`, цифра — `str.isdigit()`). + - `number_format.FormatResult(kind, value: Decimal | None = None, text: str | None = None)` — `text` заполнен у + `kind == "value"`; `display_amount(raw: Decimal, fmt: str) -> FormatResult`; `general_text(raw: Decimal) -> str`. + - `cells.excel_cell(sheet: Sheet, row: int, col: int, unparsed_formats: set[str]) -> arm_statements.Cell | None` + - `cells.grid_rows(sheet: Sheet, first_row: int, last_row: int, width: int, unparsed_formats: set[str]) -> + list[tuple[arm_statements.Cell | None, ...]]` — строки `first_row..last_row` включительно, позиция = `col - 1`, длина + каждой строки = `width`. + +Правила текста и значения (P4-3, P4-4, P4-5): +- числовая ячейка с форматом, разобранным в цифровую секцию: `value` — показываемое число (знак — от сырого, R-3), + **без** проверки диапазона `numeric(18,2)` (больше 16 целых цифр — `VALUE_OUT_OF_RANGE` выдаёт пакет); `text` — + литералы секции до и после плейсхолдеров + модуль числа с группами по три через пробел (если в секции есть + разделитель групп) и `decimals` знаками после **запятой**; «-» впереди — только если сырое отрицательно и в формате одна + секция без условия; +- секция-литерал (`[=0]"-"`, `"(647)"`): `text` — литерал, `value` — как разбирает `to_money` литерал (прочерк — `None`); +- `General`, пустой формат, формат не разобран, xlsb (формата нет): `value` — сырое `Decimal`, `text` — + `general_text(raw)`: целое — без дробной части, иначе без хвостовых нулей, разделитель — запятая, без групп и + экспоненты; неразобранный формат кладётся в `unparsed_formats`; +- дата/дата-время: `value` — `date`, `text` — `dd.mm.yyyy`; логическое — `value` bool, `text` «ИСТИНА»/«ЛОЖЬ»; текст — + `value = None`, `text = Cell.text`; пустая ячейка и не-левая-верхняя позиция объединённого диапазона — `None`; +- `confidence = 100.0`, `ref = cell_ref(sheet.name, row, col)` (у объединённого — адрес левой верхней). + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/unit/parser/test_grid.py +def test_cell_ref_quotes_sheet_name_by_excel_rule() -> None: + assert cell_ref("Баланс", 27, 3) == "Баланс!C27" + assert cell_ref("Лист_1", 2, 2) == "Лист_1!B2" + assert cell_ref("Бухгалтерский баланс. Лист 1", 14, 3) == "'Бухгалтерский баланс. Лист 1'!C14" + assert cell_ref("2024", 1, 1) == "'2024'!A1" + assert cell_ref("Отчёт д'Артаньяна", 1, 28) == "'Отчёт д''Артаньяна'!AB1" + +# tests/unit/parser/statements/test_number_format.py +@pytest.mark.parametrize( + ("raw", "fmt", "value", "text"), + [ + ("106311116.46", "#,##0,", "106311", "106 311"), + ("-646532.4", "#,##0;(#,##0)", "-646532", "(646 532)"), + ("-1500", "#,##0", "-1500", "-1 500"), + ("-1500", "#,##0;#,##0", "-1500", "1 500"), + ("1.5", "0.00", "1.50", "1,50"), + ("0", '#,##0;-#,##0;"-"', None, "-"), + ("12345678901234567", "0", "12345678901234567", "12345678901234567"), + ], +) +def test_display_text(raw: str, fmt: str, value: str | None, text: str) -> None: + shown = display_amount(D(raw), fmt) + assert (shown.kind, shown.value, shown.text) == ("value", None if value is None else D(value), text) + +def test_general_text() -> None: + assert [general_text(D(x)) for x in ("7016006635.0", "106311116.460", "-0.5")] == ["7016006635", "106311116,46", "-0,5"] + +# tests/unit/parser/statements/test_cells.py — листы собирать gridbuilder.sheet_from_rows / with_formats +def test_excel_cell_by_number_format() -> None: # спека: «Excel-ячейка с числовым форматом» + cell = excel_cell(with_formats(balance_sheet_with(row=27, col=4, value=D("106311116.46")), {(27, 4): "#,##0,"}), + 27, 4, set()) + assert cell == Cell(text="106 311", value=D("106311"), confidence=100.0, ref="Баланс!D27") + +def test_unparsed_format_keeps_raw_and_is_recorded() -> None: + formats: set[str] = set() + cell = excel_cell(with_formats(sheet_one(D("0.15")), {(1, 1): "0.0%"}), 1, 1, formats) + assert (cell.value, cell.text, formats) == (D("0.15"), "0,15", {"0.0%"}) + +def test_date_text_bool_and_merged() -> None: ... + # дата 2025-09-30 → text "30.09.2025", value date; текст «Итого» → value None; позиция внутри merged (не origin) → None + +def test_grid_rows_dense_width() -> None: ... + # 3 строки × width=6: каждая строка — кортеж длины 6, пустые позиции None, cell в позиции col-1 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd $T/api && .venv/bin/pytest tests/unit/parser/test_grid.py tests/unit/parser/statements/test_number_format.py tests/unit/parser/statements/test_cells.py -v` +Expected: FAIL — `cell_ref`, `general_text`, `cells` не определены, у `FormatResult` нет `text`. + +- [ ] **Step 3: Implement** `cell_ref` (через `column_letter`), текст в `number_format` (в `_Section` добавить + `grouping: bool`, `prefix: str`, `suffix: str`; `_scaled` больше не вызывает `quantize_money` — квантование и диапазон + в пакете), `cells.py`. Старые потребители `display_amount` (`statements/lines.py`) продолжают работать: + `kind`/`value` для чисел в пределах диапазона не меняются. +- [ ] **Step 4: Run tests** + +Run: `cd $T && make check` +Expected: зелёный (старый конвейер форм и golden не изменились: старые тесты `test_number_format` по `value` проходят; +если старый тест ждал `unparsed` для числа вне диапазона — переписать его на новое правило, это P4-4). + +- [ ] **Step 5: Commit** + +```bash +git -C $T add api/src/parser/grid.py api/src/parser/statements/number_format.py api/src/parser/statements/cells.py api/tests/unit +git -C $T commit -m "feat(statements): ячейка Excel → Cell: текст по числовому формату, value, ref <лист>!<A1> (#<issue>)" +``` + +--- + +### Task 4: Блоки форм, вид листа и шапка на словарях пакета + +Модель исполнителя: sonnet. + +**Files:** +- Create: `$T/api/src/parser/statements/forms.py`, `$T/api/src/parser/statements/header_cells.py` +- Test: `$T/api/tests/unit/parser/statements/test_forms.py`, `$T/api/tests/unit/parser/statements/test_header_cells.py` + +**Interfaces:** +- Consumes: `arm_statements.Rules` (`headers.form_titles`, `headers.okud`, `headers.okud_code`, `headers.code_caption`, + `headers.labels`, `headers.auditor`, `headers.legal_form`, `periods.relative`), `type_hint`, `is_continuation`, + `continuation_type`, `code_column`, `amount_columns`, `normalize_label`, `normalize_caption`, `parse_code`, + `parse_date`, `DocumentType`, `grid_rows` (задача 3). Нормализация текста под шаблон — таблица «Словари» раздела API + плана 1 (P4-7): метки шапки, заголовки, ОКУД, `auditor`, `legal_form` — `normalize_label`; подпись «Код» и + относительные периоды — `normalize_caption`. `normalize_text` из `src.parser.rules` здесь не используется. +- Produces (`forms.py`; `StatementRules` = `arm_statements.Rules`): + - `matches(patterns: Iterable[re.Pattern[str]], text: str) -> bool` — `search` по `normalize_label(text)` + - `caption_matches(patterns: Iterable[re.Pattern[str]], text: str) -> bool` — `search` по `normalize_caption(text)` + - `sheet_grid(sheet: Sheet, first_row: int, last_row: int) -> list[tuple[Cell | None, ...]]` — `grid_rows(sheet, + first_row, last_row, <ширина листа>, set())` (неразобранные форматы здесь не собираются — их собирает + `build_candidates`) + - `sheet_code_column(sheet: Sheet, rules: StatementRules, first_row: int, last_row: int, *, relaxed: DocumentType | + None = None) -> int | None` — столбец листа (1-based) = `code_column(sheet_grid(...), rules, relaxed=relaxed) + 1` (`None` → `None`): + правило (а)/(б) шага 2 пакета (с защитой от блока «Коды»); `relaxed` — ослабленный поиск в отрезке формы, минимум + `Rules.code_ranges.min_codes_continuation` + - `FormBlock(type_hint: DocumentType, title: tuple[int, int] | None, okud: tuple[int, int] | None, first_row: int, + last_row: int)` — `title`/`okud` — `(row, col)` ячеек-признаков + - `find_blocks(sheet: Sheet, rules: StatementRules) -> list[FormBlock]` — строки с заголовком (`type_hint(None, text)` + не `None`) или ОКУД (`type_hint(text, None)`; ячейка, для которой `is_continuation(text)`, ОКУДом блока не + считается) — кандидаты; между соседними без строки с кодом (`code_rows` по `sheet_code_column` листа) — один блок; + первый блок начинается с первой строки листа, каждый — до строки перед следующим; `type_hint` блока — + `type_hint(okud_text, title_text, rules)` + - `code_rows(sheet: Sheet, rules: StatementRules, col: int, first_row: int, last_row: int) -> list[int]` — строки, где + `parse_code(text).code` ячейки столбца `col` не `None` + - `codes_of(sheet: Sheet, rules: StatementRules, col: int, first_row: int, last_row: int) -> list[str]` + - `sheet_amount_columns(sheet: Sheet, rules: StatementRules, code_col: int, first_row: int, last_row: int) -> int` — + `len(amount_columns(sheet_grid(...), code_col - 1, rules))`: столбцы правее столбца кодов, где в строке с кодом есть + число (то же правило, что колонки сегмента без заголовка в шаге 2 пакета) + - `continuation_marker(sheet: Sheet, rules: StatementRules) -> Cell | None` — первая ячейка, для которой + `is_continuation(text, rules)` (маркер с ОКУД вне словаря — тоже маркер; тип — `continuation_type(cell.text, rules)`, + может быть `None`) + - `StatementSheetKind = Literal["statement", "continuation", "other"]`; + `classify_statement_sheet(sheet: Sheet, rules: StatementRules) -> StatementSheetKind` — есть блок → `statement`; + маркер, или ячейка с `caption_matches(headers.code_caption)` и подписью-периодом правее (`parse_date` не `None` или + `caption_matches(periods.relative)`), или `sheet_code_column(...)` не `None` → `continuation`; иначе `other` + - `is_weak_continuation(sheet: Sheet, rules: StatementRules, form_type: DocumentType) -> bool` — + `sheet_code_column(…, relaxed=form_type)` не `None`; `form_type` не `BALANCE`/`INCOME` → `False` +- Produces (`header_cells.py`): + - `header_cells(sheet: Sheet, rules: StatementRules, block: FormBlock, last_row: int, unparsed_formats: set[str]) -> + tuple[HeaderCells, Evidence]` — поиск в строках `block.first_row..last_row`; `last_row` задаёт адаптер (P4-6) + +Правила шапки (как сейчас в `statements/header.py`, но результат — `Cell` ячейки-значения через `excel_cell`): +организация — значение правее метки `organization` или в следующей строке; пусто, число или метка ОКПО — ближайшая +строка выше с признаком `legal_form`; ИНН — первая ячейка правее метки `inn`, в тексте которой 10 или 12 цифр (сам разбор +— пакет); строки ниже первой метки `auditor` для организации, ИНН и адреса не смотрятся; `okpo`, `okfs`, `okopf`, `okei`, +`address` — значение у своей метки; пара `okopf_okfs` — два числа правее в той же строке или строкой выше; `units` — +первая ячейка правее метки `units`, кроме метки ОКЕИ и чистых цифр; `report_date` — ячейка строки сразу под заголовком +формы, у которой `parse_date(text)` не `None`, иначе тройка «число | месяц | год» правее метки `date` → +`Cell(text="<д>.<мм>.<гггг>" для числового месяца или "<д> <месяц> <гггг>", value=None, confidence=100.0, ref=<ref +ячейки числа>)`; `okud` — ячейка ОКУД блока. `Evidence(okud=<ячейка ОКУД>, title=<ячейка заголовка>)`. + +- [ ] **Step 1: Write the failing tests** (листы — `sheet_from_rows`; правила — `default_rules()`) + +```python +def test_title_and_okud_without_codes_between_form_one_block(rules: StatementRules) -> None: ... + # «Бухгалтерский баланс» (стр. 3), «Форма по ОКУД | 0710001» (стр. 5), таблица ниже → один блок BALANCE, + # title=(3, c), okud=(5, c2), first_row=1 + +def test_second_title_after_codes_opens_new_block(rules: StatementRules) -> None: ... + # ББ с кодами 1110…1600, ниже «Отчет о финансовых результатах» и 2110… → блоки BALANCE и INCOME, граница — строка заголовка ОФР + +def test_okud_wins_over_title(rules: StatementRules) -> None: + blocks = find_blocks(sheet_from_rows([["Бухгалтерский баланс"], ["по ОКУД", "0710002"]]), rules) + assert [block.type_hint for block in blocks] == [DocumentType.INCOME] + +def test_report_title_alone_is_not_a_form(rules: StatementRules) -> None: + assert find_blocks(sheet_from_rows([["Бухгалтерская отчетность"]]), rules) == [] + +def test_continuation_marker_is_not_a_block(rules: StatementRules) -> None: + sheet = sheet_from_rows([["Форма 0710001 с.2"], ["Пассив", "Код", "На 30.09.2025"], ["Капитал", "1310", 10]]) + assert find_blocks(sheet, rules) == [] + assert classify_statement_sheet(sheet, rules) == "continuation" + +def test_code_column_ignores_title_codes_block(rules: StatementRules) -> None: # Review Focus 1 + # столбец F: «Коды», 0710001, 30, 09, 2025, 12345678, 7700000000, 12267, 16, 384 (один код из девяти); + # столбец C: 1110, 1150, 1170, 1100, 1210, 1230 → sheet_code_column(...) == 3 + +def test_amount_columns_counts_numeric_columns_right_of_code(rules: StatementRules) -> None: ... + # код в C, числа в D, E, F в строках с кодами, текст «Изменение» в G без чисел → sheet_amount_columns(...) == 3 + +def test_weak_and_other_sheets(rules: StatementRules) -> None: ... + # лист с единственной строкой «Чистая прибыль | 2400 | 10 | 8» → classify «other», + # is_weak_continuation(…, DocumentType.INCOME) True, (…, DocumentType.BALANCE) False; + # лист «Примечания» с текстом → «other», is_weak_continuation(…, INCOME) False + +def test_marker_with_unknown_okud_is_continuation(rules: StatementRules) -> None: ... + # лист «Форма 0710000 с.2» + таблица с кодами → find_blocks == [], classify «continuation», + # continuation_marker(...).text == "Форма 0710000 с.2", continuation_type(...) is None + +def test_labels_with_punctuation_match(rules: StatementRules) -> None: + assert matches(rules.headers.labels["address"], "Местонахождение (адрес)") + assert matches(rules.headers.labels["okopf_okfs"], "по ОКОПФ / ОКФС") + assert matches(rules.headers.legal_form, "АО «Пример»") + +def test_header_cells_of_1c_block(rules: StatementRules) -> None: ... + # «Организация | АО «Пример»», «ИНН | 7700000000», «по ОКПО | 12345678», «по ОКОПФ / ОКФС | 12267 | 16» строкой выше + # метки, «Единица измерения: | в тыс. рублей | по ОКЕИ | 384», подзаголовок «на 30 сентября 2025 г.»: + # cells.organization_name.text == "АО «Пример»", .inn.ref == "Баланс!C5", (.okopf.text, .okfs.text) == ("12267", "16"), + # .report_date.text == "на 30 сентября 2025 г."; parse_header(cells, rules) → units "тыс. руб.", report_date 2025-09-30 + +def test_auditor_inn_is_not_taken(rules: StatementRules) -> None: ... +def test_organization_by_legal_form_above_label(rules: StatementRules) -> None: ... # НПЗ +def test_report_date_from_triple(rules: StatementRules) -> None: ... + # «Дата (число, месяц, год) | 30 | 09 | 2025» → text "30.09.2025"; «30 | сентября | 2025» → "30 сентября 2025"; + # parse_header даёт 2025-09-30 в обоих случаях + +def test_units_only_by_okei(rules: StatementRules) -> None: # спека: «Единицы только кодом ОКЕИ» + # «по ОКЕИ | 384», метки «Единица измерения» нет → cells.units is None, cells.okei.text == "384"; + # head = parse_header(cells, rules): (head.units.value, head.okei.value) == ("тыс. руб.", "384") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd $T/api && .venv/bin/pytest tests/unit/parser/statements/test_forms.py tests/unit/parser/statements/test_header_cells.py -v` +Expected: FAIL — модулей нет. + +- [ ] **Step 3: Implement `forms.py`, `header_cells.py`** по Interfaces (донор логики — `statements/detect.py` и + `statements/header.py`; сами модули-доноры не правятся, их удаляет задача 9). Если метка шапки не находится словарём + пакета, а словарём tabular-parser находится — это расхождение словарей: находка `O-n`, словарь пакета не правится. +- [ ] **Step 4: Run tests** + +Run: `cd $T && make check` +Expected: зелёный; `test_no_coordinates.py` проходит (новые модули без координат). + +- [ ] **Step 5: Commit** + +```bash +git -C $T add api/src/parser/statements/forms.py api/src/parser/statements/header_cells.py api/tests/unit/parser/statements +git -C $T commit -m "feat(statements): блоки форм, вид листа и шапка-ячейки на словарях arm_statements (#<issue>)" +``` + +--- + +### Task 5: Адаптер книги → `FormCandidate` + +Модель исполнителя: sonnet. + +**Files:** +- Create: `$T/api/src/parser/statements/adapter.py` +- Modify: `$T/api/src/parser/detect.py` (вынести `is_osv_sheet`, поведение `classify_sheet` не меняется) +- Test: `$T/api/tests/unit/parser/statements/test_adapter.py`, + `$T/api/tests/fixtures/test_statements_adapter_real.py` (маркер `fixtures`) + +**Interfaces:** +- Consumes: задачи 3–4; `FormCandidate`, `Evidence`, `HeaderCells`, `Source`, `DocumentType`, `make_warning`, + `WarningCode`, `sheet_joins`, `continuation_type`, `Deadline`, `SheetKind`. +- Produces: + - `detect.is_osv_sheet(sheet: Sheet, rules: Rules) -> bool` — признак ведомости в первых `SHEET_MARKER_ROWS` строках + - `adapter.SheetPlan(osv: tuple[Sheet, ...], statements: tuple[tuple[Sheet, SheetKind], ...], other: tuple[Sheet, ...])` + - `adapter.plan_sheets(sheets: Sequence[Sheet], is_osv: Callable[[Sheet], bool], rules: StatementRules) -> SheetPlan` — + по порядку: ОСВ; `statement` → `SheetKind.STATEMENT`; `continuation` → `STATEMENT_CONTINUATION`; `other` сразу после + листа формы или продолжения и `is_weak_continuation(лист, rules, <type_hint последнего блока последнего листа + формы>)` → `STATEMENT_CONTINUATION`; иначе `other`. Лист ОСВ или «прочее» прерывает «сразу после». + - `adapter.build_candidates(sheets: Sequence[tuple[Sheet, SheetKind]], rules: StatementRules, deadline: Deadline, *, + xlsb: bool) -> list[FormCandidate]` + +Алгоритм `build_candidates` (спека «Шаг 1 — многостраничная форма», §11.2 п. 2–4; P4-6, P4-8, P4-10): +1. Лист `STATEMENT`: для каждого блока — новый кандидат: `source=EXCEL`, `locator="sheet:<имя>"`, `type_hint` блока, + `evidence` и `header` — `header_cells(…, last_row=<строка перед первой строкой с кодом столбца кодов блока>)` + (столбец — `sheet_code_column` блока, строки — `code_rows`; кодов нет — `block.last_row`), `rows = grid_rows(sheet, + block.first_row, block.last_row, …)`. +2. Лист `STATEMENT_CONTINUATION`: открытая форма — последний кандидат с `type_hint` `BALANCE`/`INCOME`. Маркер есть и его + тип не `None` и не равен типу формы → отдельный кандидат (`type_hint` = тип маркера) + `CONTINUATION_TYPE_CONFLICT`. + Маркера нет → `sheet_joins(type, codes, sheet_amount_columns(лист), sheet_amount_columns(первый лист формы в её + блоке), rules)`, где столбец кодов листа — `sheet_code_column(лист, …)`, а если его нет — + `sheet_code_column(лист, …, relaxed=type)` (как сегмент шага 2 пакета), `codes` — `codes_of` по нему; + `False` → отдельный кандидат (`type_hint=None`) + `SHEET_NOT_JOINED`. Открытой формы нет → отдельный кандидат + (`type_hint` = тип маркера или `None`) без предупреждения. У отдельного кандидата `header = HeaderCells()`, + `evidence = Evidence()`, `rows` — все строки листа. Иначе — присоединение: `segment_breaks` += текущее число строк + сетки, строки листа (все строки листа) дописываются, `locator` += `,<имя>`. +3. Ширина: строки всех листов кандидата выравниваются по наибольшей ширине (дописываются `None`). +4. Предупреждения кандидата (`make_warning` с `WarningCode`, документа): `xlsb` → `XLSB_NO_FORMATS` у каждого + кандидата; непустой набор + неразобранных форматов всех листов кандидата → одно `NUMBER_FORMAT_UNPARSED` с перечнем (`«fmt»` через запятую, + длинные — до 30 символов с «…»). +5. `deadline.check(f"лист «{name}»: форма")` перед каждым листом. + +- [ ] **Step 1: Write the failing tests** + +```python +def test_balance_with_marked_continuation_and_income(rules: StatementRules) -> None: # спека: «Книга ББ + ОФР» + candidates = build_candidates([(balance, STATEMENT), (balance_2_marked, STATEMENT_CONTINUATION), (ofr, STATEMENT)], + rules, NO_DEADLINE, xlsb=False) + assert [(c.type_hint, c.locator) for c in candidates] == [ + (DocumentType.BALANCE, "sheet:Баланс,Баланс (2)"), (DocumentType.INCOME, "sheet:ОФР")] + assert candidates[0].segment_breaks == (balance.n_rows,) + assert len({len(row) for row in candidates[0].rows}) == 1 + assert candidates[0].source is Source.EXCEL + +def test_refs_of_cells(rules: StatementRules) -> None: # спека: «Ссылки ячеек» + # код 1110 в C27 листа «Баланс» → ячейка сетки с text "1110" имеет ref "Баланс!C27"; + # у листа «Бухгалтерский баланс. Лист 1» — "'Бухгалтерский баланс. Лист 1'!C27" + +def test_marker_type_conflict_is_separate_candidate(rules: StatementRules) -> None: ... + # ББ, затем лист с «Форма 0710002 с.2» → 2 кандидата; у второго type_hint INCOME и + # [(w.code, w.severity) for w in c.warnings] == [("CONTINUATION_TYPE_CONFLICT", Severity.ERROR)] + +def test_single_line_continuation_joins(rules: StatementRules) -> None: # спека: «Лист-продолжение с одной строкой» + # ОФР (3 колонки сумм), лист без шапки с единственной строкой 2400 → 1 кандидат, segment_breaks == (n_rows ОФР,) + +def test_wider_sheet_with_three_codes_is_not_joined(rules: StatementRules) -> None: # «Лист другой ширины с тремя кодами» + # ББ (3 колонки сумм), лист без маркера и шапки с 1210, 1230, 1250 и 4 колонками → 2 кандидата, + # у второго SHEET_NOT_JOINED (error), type_hint None + +def test_lonely_continuation_has_no_warning(rules: StatementRules) -> None: ... +def test_two_forms_on_one_sheet_share_locator(rules: StatementRules) -> None: ... +def test_xlsb_and_unparsed_formats_warnings(rules: StatementRules) -> None: ... + # xlsb=True → XLSB_NO_FORMATS у каждого кандидата; форматы «0.0%» на двух листах формы → одно NUMBER_FORMAT_UNPARSED + +def test_plan_sheets_weak_continuation_only_after_form(rules: StatementRules) -> None: ... + # [ОФР, лист «2400»] → второй — STATEMENT_CONTINUATION; [ОСВ, лист «2400»] → other; [ОФР, «Примечания», «2400»] → other ×2 + +# tests/fixtures/test_statements_adapter_real.py +def test_npz_candidates(fixtures_dir: Path) -> None: # Review Focus 2 + # read_book(НПЗ) → plan_sheets → build_candidates: [(type_hint, locator.count(",") + 1, len(segment_breaks))] == + # [(BALANCE, 5, 4), (INCOME, 3, 2)]; ни у одного кандидата нет SHEET_NOT_JOINED / CONTINUATION_TYPE_CONFLICT + +def test_unagrande_title_block_header(fixtures_dir: Path) -> None: # Review Focus 1 + # «unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls» → header.okopf.text == "12267", header.inn.text содержит "7728757141" +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd $T/api && .venv/bin/pytest tests/unit/parser/statements/test_adapter.py tests/fixtures/test_statements_adapter_real.py -v` +Expected: FAIL — `adapter`, `is_osv_sheet` не определены. + +- [ ] **Step 3: Implement** `is_osv_sheet` (`classify_sheet` вызывает его, поведение то же) и `adapter.py`. +- [ ] **Step 4: Run tests** + +Run: `cd $T && make check` +Expected: зелёный; golden и манифест не меняются (книга ещё идёт старым конвейером). + +- [ ] **Step 5: Commit** + +```bash +git -C $T add api/src/parser/detect.py api/src/parser/statements/adapter.py api/tests +git -C $T commit -m "feat(statements): адаптер шага 1 — книга → FormCandidate, склейка листов, segment_breaks (#<issue>)" +``` + +--- + +### Task 6: Предупреждения ОСВ с кодами `OSV_*` (вывод пока прежний) + +Модель исполнителя: sonnet. + +**Files:** +- Create: `$T/api/src/parser/osv_warnings.py` +- Modify: `$T/api/src/parser/checks.py` (`run_checks`), `$T/api/src/parser/osv.py` (`parse_sheet`), `$T/api/src/parser/book.py` + (вызов), тесты `test_checks.py`, `test_book_parser.py` (только места вызова) +- Test: `$T/api/tests/unit/parser/test_osv_warnings.py` + +**Interfaces:** +- Produces (`osv_warnings.py`): + - `OsvNote(code: str, message: str)` — frozen dataclass + - коды (P4-12): `OSV_HIERARCHY_BY_OUTLINE|INDENT|ACCOUNT_PATTERN|SUMS|TRIVIAL` (`hierarchy_note(strategy: str) -> + OsvNote`), `OSV_HEADER` (предупреждения шапки `doc.warnings`), `OSV_ROWS` (предупреждения строк `collect_body_rows`), + `OSV_INN_ROWS` (`glue.warnings`), `OSV_NO_DATA_ROWS`, `OSV_BALANCE_IDENTITY`, `OSV_PARENT_SUM`, `OSV_TOTAL` (итог не + найден или не равен корню), `OSV_XLSB_NO_OUTLINE` (заметка читателя, задача 7; «xls без кодовой страницы» — не + код ОСВ, а предупреждение файла `XLS_NO_CODEPAGE` пакета, P4-13) + - `legacy_line(sheet_name: str, note: OsvNote) -> str` — `f"sheet={sheet_name} {note.message}"` (вывод 1.x до задачи 7) +- Produces (`checks.py`): `run_checks(rows, exempt=None) -> tuple[OsvChecks, list[OsvNote]]` — тексты прежние, коды по + виду проверки. +- Produces (`osv.py`): `SheetResult(document: OsvDocument, notes: tuple[OsvNote, ...], ok: bool)`; + `OsvParser.parse_sheet(sheet, deadline) -> SheetResult`; сообщение `hierarchy_note` пока `hierarchy=<стратегия>` + (текст меняет задача 7). + +- [ ] **Step 1: Write the failing tests** + +```python +def test_check_notes_have_codes() -> None: + _, notes = run_checks(rows_with_identity_violation_and_parent_mismatch()) + assert [note.code for note in notes] == ["OSV_BALANCE_IDENTITY", "OSV_PARENT_SUM"] + +def test_parse_sheet_notes_start_with_hierarchy(osv_sheet: Sheet) -> None: + result = OsvParser(load_rules()).parse_sheet(osv_sheet, NO_DEADLINE) + assert result.notes[0].code.startswith("OSV_HIERARCHY_BY_") + assert all(note.code.startswith("OSV_") for note in result.notes) + +def test_legacy_lines_are_unchanged(osv_sheet: Sheet) -> None: + # book.parse(...).metrics.warnings на синтетике ОСВ — те же строки, что до задачи (сравнить с литералом из текущего теста) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd $T/api && .venv/bin/pytest tests/unit/parser/test_osv_warnings.py -v` +Expected: FAIL — модуля нет. + +- [ ] **Step 3: Implement** по Interfaces; `book.py` рендерит заметки `legacy_line`, порядок прежний. +- [ ] **Step 4: Run tests** + +Run: `cd $T && make check && make golden-check` +Expected: зелёный; `golden-check` — код 0 (снимки ОСВ и форм не изменились). + +- [ ] **Step 5: Commit** + +```bash +git -C $T add api/src/parser api/tests +git -C $T commit -m "refactor(osv): предупреждения листа ОСВ с кодами OSV_* (вывод 1.x прежний) (#<issue>)" +``` + +--- + +### Task 7: Переключение на общий код и контракт v2.0 + +Модель исполнителя: sonnet (интеграция). Самая крупная задача: модели и копия схем, сборка книги, путь ОСВ и воркер +меняются вместе, иначе `make check` красный (P4-1). Golden и манифест — задача 8; до неё проверка **без фикстур**. + +**Files:** +- Modify: `$T/api/contracts/**` (копия `$D/contracts/*.schema.json` верхнего уровня и `$D/contracts/examples/*.json`; файлы + `*.v1.schema.json` и `examples/v1/` не копируются), `$T/api/src/contract/{common,request,result,message,__init__}.py`, + `$T/api/src/parser/{__init__,book,detect,osv,cli}.py`, `$T/api/src/parser/readers/__init__.py`, + `$T/api/src/core/worker.py`, `$T/api/src/app.py` +- Create: `$T/api/src/parser/statements/pipeline.py` +- Test: `$T/api/tests/result_contract.py`, `$T/api/tests/golden_helpers.py` (`VOLATILE`), + `$T/api/tests/unit/test_contract.py`, `$T/api/tests/unit/test_contract_v12.py` → `test_contract_v20.py`, + `$T/api/tests/unit/parser/test_book_parser.py`, `$T/api/tests/unit/parser/statements/test_pipeline.py` (новый), + `$T/api/tests/unit/test_worker.py`, `$T/api/tests/unit/parser/test_cli.py`, `$T/api/tests/unit/parser/test_readers.py`, + `$T/api/tests/synthetic/test_statement_synthetic_files.py`, `$T/api/tests/synthetic/test_synthetic_files.py`, + `$T/api/tests/integration/test_roundtrip.py` + +**Interfaces:** +- Consumes: задачи 3–6; `normalize_candidate`, `envelope_status`, `make_warning`, `WarningCode`, `to_contract`, + `warning_to_contract`, `default_rules`, `statements_version`, `DocStatus`, `RulesError`. +- Produces (контракт, `src/contract/`): + - `CONTRACT_VERSION = "2.0"` (результат и конверт); `SCHEMA_VERSION_PATTERN = r"^2\.\d+$"`; + `REQUEST_SCHEMA_VERSION_PATTERN = r"^1\.\d+$"`, `RequestSchemaVersion` — у `RecognitionRequest` + - `ResultWarning(code: str (pattern ^[A-Z][A-Z0-9_]*$), severity: Literal["error", "info"], message: str, + line_refs: list[str] | None, column_index: int | None, field: HeaderFieldName | None)`; `FileWarning(ResultWarning)` + + `locator: str | None`; `Metrics.warnings: list[FileWarning] | None` + - `ServiceInfo.statements_version: str | None` (`min_length=1`); `RecognitionResult` — валидатор: при наличии + `StatementDocument` поле обязательно (как `allOf` схемы) + - `OsvDocument` + `status: DocumentStatus`, `warnings: list[ResultWarning]` + - `StatementHeader` — поля схемы v2.0 (`form_name`, по каждому полю `<поле>`, `<поле>_text`, `<поле>_confidence`, + `needs_review_fields`); `StatementColumn` (+ `label_confidence`, `period_kind`); `StatementValue` (+ `text`, + `needs_review`); `StatementLine` (`row_index`, `ref`, `line_status`, `line_code: str | None`, `line_code_text`, + `line_code_confidence`, `line_name: str | None`, `line_name_confidence`, `parent_ref`, `needs_review`, `values`; + валидатор: `line_code` не `None` ⇔ `line_status == "ok"`); `FailedRelation` (+ `codes`, `expected`/`actual` — + `Money | None`: `null` — цель или все члены пусты, план 1 P1-21); `StatementChecks` без `duplicate_line_codes`; `StatementDocument` (`document_type: Literal["BALANCE", + "INCOME", "DISCLOSURE"]`, `form_variant: Literal["full", "simplified"] | None` обязателен, `status`, `warnings`; + валидатор `checks` по `allOf` схемы) +- Перевод в контракт — пакетом (план 1, задача 19), своего `statements/contract.py` нет: документ формы — + `StatementDocument.model_validate(to_contract(document))` (шапка плоская, `form_name` из `document.header.form_name`, + без `column_key`, `checks` нет ⇒ ключа нет); предупреждение файла — `FileWarning.model_validate(warning_to_contract(w, + file_level=True))`; предупреждение листа ОСВ (`OsvNote` → `ResultWarning(code, severity="info", message)`) строится + моделью напрямую (коды `OSV_*` не в словаре пакета) +- Produces (`statements/pipeline.py`): + - `StatementsOutcome(documents: tuple[StatementDocument, ...], file_warnings: tuple[FileWarning, ...])` + - `StatementPipeline(rules: StatementRules)` с методом `parse(sheets: Sequence[tuple[Sheet, SheetKind]], deadline: + Deadline, *, xlsb: bool) -> StatementsOutcome` — `build_candidates` → для каждого кандидата `deadline.check` и + `normalize_candidate` (P4-11) → `to_contract` / `warning_to_contract` (заголовок формы — уже в + `document.header.form_name`) +- Produces (книга): `BookParser(rules, *, osv: OsvParser, statements: StatementPipeline, statement_rules: + StatementRules, max_bytes, timeout_seconds, clock)`; `ParseOutcome` прежний (документы — модели v2.0, + `metrics.warnings` — `FileWarning`); `readers.BookRead(sheets, warnings, detected, mismatch: str | None)` — строка + несовпадения формата больше не в `warnings`; `detect.classify_sheet(sheet, rules, statement_rules: StatementRules | + None = None)` — ветка форм через `forms.classify_statement_sheet` (`None` → `default_rules()`) +- Produces (`src/parser/__init__.py`): `require_statements_version(path: Path | None = None) -> str` — + `statements_version(path)`, `None` → `RuntimeError("копия arm_statements без VERSION: разложите её sync-statements")` + (P4-16); `get_parser` грузит `default_rules()` (`RulesError` → `RuntimeError` с текстом ошибки) и собирает + `StatementPipeline` +- Produces (воркер): `RecognitionWorker(..., statements_version: str)` → `ServiceInfo(name, version, + statements_version)` в конверте и файле результата; `app.py` передаёт `require_statements_version()`; CLI — тоже. + +Сборка книги (`BookParser.parse`; спека «Статус книги», «Путь ОСВ в контракте v2.0»): +1. лимиты и чтение — как сейчас; `read.mismatch` → `FILE_TYPE_MISMATCH` (файл, `info`, сообщение — прежний текст + «file_type=…, но содержимое …: прочитано как …»); +2. `plan_sheets(read.sheets, is_osv_sheet, statement_rules)`; листы ОСВ — по порядку `parse_sheet`: документ получает + `status = done if ok else needs_check` и `warnings` из заметок (`severity = info`), плюс заметка читателя + `OSV_XLSB_NO_OUTLINE` в каждый документ ОСВ книги (P4-13); заметка «xls без кодовой страницы» — предупреждение + файла `make_warning(WarningCode.XLS_NO_CODEPAGE, …)` (`info`, без `locator`) в `metrics.warnings`, в любой книге xls + (с ОСВ, формами или и тем, и другим); сообщение иерархии — «иерархия строк + — по группировке листа (outline)» / «по отступам (indent)» / «по кодам счетов (account_pattern)» / «по суммам (sums)» / + «плоский список (trivial)»; +3. листы `other` → `SHEET_OTHER` (файл, `info`, `locator = sheet:<имя>`, сообщение «лист без признаков ОСВ и формы»); +4. формы — `StatementPipeline.parse(plan.statements, deadline, xlsb=read.detected == "XLSB")`; +5. документов нет → `INVALID_DOCUMENT` (сообщение прежнее); иначе `status = envelope_status(статусы ОСВ и форм как + DocStatus, предупреждения файла)`; `metrics.warnings` — все предупреждения файла по порядку листов; `rows` — как сейчас + (строки данных ОСВ + `len(lines)`); +6. ошибка листа ОСВ (`ParserError`) — исход `error` как сейчас; предупреждение «форма не разобрана: книга завершилась + ошибкой листа ОСВ» больше не пишется (P4-14), `metrics.warnings` ошибки — собранные до неё предупреждения файла. + +- [ ] **Step 1: Копия контракта** + +```bash +cp $D/contracts/recognition-request.schema.json $D/contracts/recognition-result.schema.json \ + $D/contracts/recognition-result-message.schema.json $T/api/contracts/ +cp $D/contracts/examples/*.json $T/api/contracts/examples/ +``` + +- [ ] **Step 2: Write the failing tests** (выборка; остальные тесты файлов из списка правятся под v2.0 по смыслу) + +```python +# tests/unit/test_contract_v20.py +def test_docs_examples_validate_with_models() -> None: + for name in ("result-osv-minimal", "result-osv-needs-check", "result-statement-excel", "result-statement-minimal", + "result-error"): + payload = json.loads((EXAMPLES_DIR / f"{name}.json").read_text(encoding="utf-8")) + assert RecognitionResult.model_validate(payload).to_wire() == payload + +def test_statement_without_statements_version_is_rejected() -> None: ... +def test_unreadable_line_with_code_is_rejected() -> None: ... # спека: «Строка без кода с кодом в поле» +def test_request_stays_v1_and_result_is_v2() -> None: + assert CONTRACT_VERSION == "2.0" + RecognitionRequest.model_validate(load_example("request-tabular")) # schema_version "1.0" проходит + +# tests/unit/parser/statements/test_pipeline.py +def test_document_converts_to_contract(statement_rules: StatementRules) -> None: + outcome = StatementPipeline(statement_rules).parse([(balance_sheet, SheetKind.STATEMENT)], NO_DEADLINE, xlsb=False) + document = outcome.documents[0] + assert document.header.form_name == "Бухгалтерский баланс" + assert (document.header.inn, document.header.inn_text, document.header.inn_confidence) == ("7700000000", "7700000000", 100) + assert document.lines[0].ref == "Баланс!C10" + assert_valid_result(ParseOutcome(status=ResultStatus.DONE, documents=outcome.documents)) + +# tests/unit/parser/test_book_parser.py +def test_osv_document_has_status_and_osv_warnings() -> None: # спека: «Предупреждение ОСВ» + outcome = parse_book(osv_with_identity_violation) + doc = outcome.documents[0] + assert doc.status == "needs_check" + assert all(w.severity == "info" and w.code.startswith("OSV_") for w in doc.warnings) + assert "OSV_BALANCE_IDENTITY" in {w.code for w in doc.warnings} + assert not any(w.message.startswith("sheet=") for w in outcome.metrics.warnings) + +def test_other_sheet_is_file_warning() -> None: # спека: «Лист «прочее»» + # книга [ББ, «Примечания»] → документов 1, metrics.warnings == [SHEET_OTHER info, locator "sheet:Примечания"] + +def test_file_type_mismatch_is_info_file_warning() -> None: ... +def test_xls_without_codepage_is_file_warning() -> None: ... + # книга xls (ОСВ + ББ) с заметкой читателя «нет кодовой страницы» → metrics.warnings содержит + # {code: "XLS_NO_CODEPAGE", severity: "info"}; у документов ОСВ нет OSV_XLS_NO_CODEPAGE +def test_disclosures_only_book_is_done() -> None: # спека: «Книга только из пояснений»; Review Focus 5 + # листы «0710003» (ОКУД, без кодов) и «Пояснения к бухгалтерскому балансу» (без кодов) → + # [(d.document_type, d.status) for d in docs] == [("DISCLOSURE", "done")] * 2, outcome.status is DONE + +def test_disclosure_title_with_balance_codes() -> None: # Review Focus 5 + # «Пояснения к бухгалтерскому балансу» + коды 1110…1700 → BALANCE и TYPE_CONFLICT (error) + +def test_xlsb_form_scale_suspect() -> None: # Review Focus 4 + # write_xlsb({"Баланс": форма ББ, «тыс. руб.», 40 % дробных}) → warnings ⊇ {XLSB_NO_FORMATS info, + # XLSB_SCALE_SUSPECT error}, status needs_check; без дробных → только XLSB_NO_FORMATS, status done + +def test_book_without_osv_and_forms_is_invalid_document() -> None: ... +def test_documents_order_osv_then_forms() -> None: ... + +# tests/unit/test_worker.py +def test_result_carries_statements_version() -> None: # спека: «Версия общего кода в результате» + # RecognitionWorker(..., statements_version="abc1234") → result.service.statements_version == "abc1234"; + # конверт и ack processing — schema_version "2.0"; запрос "1.0" принимается, "2.0" → SCHEMA_MISMATCH без ack + +def test_require_statements_version_fails_without_file(tmp_path: Path) -> None: + with pytest.raises(RuntimeError, match="VERSION"): + require_statements_version(tmp_path / "VERSION") +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cd $T/api && ARM_KA_FIXTURES=/nonexistent .venv/bin/pytest tests/unit tests/synthetic -m "not integration" -x -q` +Expected: FAIL (модели 1.2, конвейер старый). + +- [ ] **Step 4: Implement** по Interfaces и «Сборке книги». `tests/result_contract.py`: `service` с + `"statements_version": "test"`; `golden_helpers.VOLATILE` += `("service", "statements_version")`; `render` и CLI + получают версию через `require_statements_version()`. Старые модули форм (`statement.py` и др.) больше не вызываются + книгой, но остаются до задачи 9 вместе со своими тестами. +- [ ] **Step 5: Run tests** + +Run: `cd $T && ARM_KA_FIXTURES=/nonexistent make check` +Expected: зелёный (тесты на реальных фикстурах пропущены — их чинит задача 8; тест идентичности копии контракта против +`$D` — PASSED, не SKIPPED: `cd api && .venv/bin/pytest -rs -k vendored_contracts`). + +- [ ] **Step 6: Commit** + +```bash +git -C $T add api +git -C $T commit -m "feat: формы через arm_statements, контракт результата и конверта v2.0, статус и OSV_* у ОСВ (#<issue>)" +``` + +--- + +### Task 8: Golden и манифест реальных форм + +Модель исполнителя: sonnet (сверка на реальных данных). + +**Files:** +- Modify: `$F/golden/expected_osv_parse/**`, `$F/golden/expected_statement_parse/**` (только `make golden-update`) +- Modify: `$T/api/tests/statements_manifest.yaml`, `$T/api/tests/statement_helpers.py`, + `$T/api/tests/fixtures/test_statements_property.py`, `$T/api/tests/fixtures/test_statements_number_format.py`, + прочие `tests/fixtures/*` по факту падений +- Create: `$T/api/tests/fixtures/test_statements_real.py` + +**Interfaces:** +- Consumes: книга задачи 7. +- Produces: `DocumentExpectation` + `error_warnings: list[str] | None` (P4-18: точное множество кодов предупреждений + документа уровня `error`; `None` — не проверяется). + +- [ ] **Step 1: Обновить golden** + +Run: `cd $T && make golden-update` +Expected: перезаписаны снимки ОСВ и форм, в выводе unified diff. + +- [ ] **Step 2: Проверить, что ОСВ изменилась только по ключам v2.0** + +```bash +cd $F && git diff --name-only -- golden/expected_osv_parse | while read -r f; do + git show "HEAD:$f" > /tmp/old.json + python3 - /tmp/old.json "$f" <<'EOF' +import json, sys +old, new = (json.load(open(p, encoding="utf-8")) for p in sys.argv[1:]) +assert old["status"] == new["status"], (sys.argv[2], old["status"], new["status"]) +for d in (old, new): + d.pop("schema_version", None); d.get("metrics", {}).pop("warnings", None) + for doc in d.get("documents", []): doc.pop("status", None); doc.pop("warnings", None) +assert old == new, sys.argv[2] +EOF +done; echo "osv ok" +``` +Expected: `osv ok` без `AssertionError` (временный файл — в scratchpad исполнителя, не `/tmp`, если окружение требует). + +- [ ] **Step 3: Просмотреть снимки форм** по каждому файлу `expected_statement_parse/**`: статусы документов против + манифеста, предупреждения `error` с объяснением, строки `detail`/`unreadable`/`missing` — глазами против Review Focus + 1–3. Каждое изменение статуса документа против манифеста — с причиной в отчёте задачи (пойдёт в описание PR). +- [ ] **Step 4: Манифест** (спека «Манифест ожиданий на реальных формах»): `failed_relations: ["1600 = 1700"]` у + `unagrande-co/2024-FY/Б.Б. Лист 2.xls`, `unagrande-group/2024-FY/ББ. Лист 2 (2).xls`, + `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` (было `[]`, причина «страница 2 без шапки» остаётся); страницы 1 «только + актив» сохраняют `["1600 = 1700"]`; статусы `done` (Межениновская ×2, НПЗ ×2, ОФР «Лист 1» Унагранде ×2) не меняются; + `error_warnings` — у каждого документа по прогону (например, страницы 2 — `["NO_ORGANIZATION_NO_INN"]` или как покажет + прогон, с объяснением); остальные записи — по прогону, каждое изменение — строкой в отчёт. +- [ ] **Step 5: Property-тест под v2.0** (`test_statements_property.py`): статус — `document.status`; удалить проверку + уникальности кодов (повторы законны); `value.column_index ∈ {c.column_index for c in document.columns}`; + `needs_check` объясним: есть предупреждение `error`, или `checks.relations_failed`, или `needs_review` у строки/значения, + или `header.needs_review_fields`, или строка `unreadable`/`missing`; `ref` уникальны в документе; `error_warnings` + манифеста сверяются точным множеством. +- [ ] **Step 6: Тесты Review Focus на реальных книгах** (`tests/fixtures/test_statements_real.py`, маркер `fixtures`) + +```python +def test_unagrande_title_codes_block(fixtures_dir: Path) -> None: # Review Focus 1 + for key in TITLE_BLOCK_FILES: # пять файлов «Лист 1» Унагранде + for doc in statement_docs(parse(fixtures_dir, key)): + assert doc.header.okopf == "12267" + assert not any("12267" in (line.line_code_text or "") for line in doc.lines) + +def test_npz_documents(fixtures_dir: Path) -> None: # Review Focus 2; спека «НПЗ «Реализация иного имущества»» + balance, income = statement_docs(parse(fixtures_dir, NPZ)) + assert (balance.status, income.status) == ("done", "done") + bad = {"SHEET_NOT_JOINED", "SEGMENT_COLUMNS_MISMATCH", "SEGMENT_WITHOUT_CODES", "HEADER_ROW_NOT_FOUND"} + assert not bad & {w.code for d in (balance, income) for w in d.warnings} + cols = {c.column_index for c in balance.columns} + assert all(v.column_index in cols for line in balance.lines for v in line.values) + by_ref = {line.ref: line for line in income.lines} + other = next(l for l in income.lines if l.line_name and l.line_name.startswith("Реализация иного имущества")) + assert other.line_status == "detail" and by_ref[other.parent_ref].line_code == "2340" + assert any(l.line_code == "2400" and l.ref.startswith("'Отчет о финансовых результата_2'") for l in income.lines) + +def test_mezheninovskaya_numbering_and_signatures(fixtures_dir: Path) -> None: # Review Focus 3 + for key in MEZHENINOVSKAYA: + (doc,) = statement_docs(parse(fixtures_dir, key)) + assert doc.status == "done" and doc.header.inn == "7016006635" + assert not any(l.line_code_text == "2" for l in doc.lines) + assert not any(l.line_status in ("unreadable", "missing") for l in doc.lines) +``` + (точное имя листа ОФР НПЗ — по golden; если тест 2 или 3 падает на ролях строк — стоп, находка для плана 1.) + +- [ ] **Step 7: Run tests** + +Run: `cd $T && make check && make golden-check` +Expected: зелёный с фикстурами; `golden-check` — 0. + +- [ ] **Step 8: Commit** (два репозитория, отдельные коммиты) + +```bash +git -C $F add golden +git -C $F commit -m "golden: снимки ОСВ и форм под контракт v2.0 (arm-tabular-parser#<issue>)" +git -C $T add api/tests +git -C $T commit -m "test: манифест реальных форм и Review Focus под общий код и v2.0 (#<issue>)" +``` + +--- + +### Task 9: Удаление собственного кода шагов 2–8 и словарей форм + +Модель исполнителя: haiku (удаление по списку, без нового кода). + +**Files:** +- Delete: `$T/api/src/parser/statements/{checks,columns,detect,header,lines,merge,statement}.py`, + `$T/api/rules/statement_{headers,periods,checks}.yaml`, + `$T/api/tests/unit/parser/statements/{test_checks,test_columns,test_detect,test_header,test_lines,test_statement}.py`, + `$T/api/tests/unit/parser/test_statement_rules.py` +- Modify: `$T/api/src/parser/rules.py` (убрать `StatementHeaderRules`, `StatementPeriodRules`, модели §7, `FormType`, + `StatementLabel`, поля `Rules.statement_*` и их загрузку), `$T/api/tests/rules_coverage.py` (`DICTIONARIES`), + `$T/api/tests/unit/parser/test_rules.py`, `$T/api/tests/fixtures/test_rules_coverage.py` (без `StatementParser`), + `$T/api/src/parser/numbers.py` (удалить функции без вызывающих после удаления — только по `grep`), `$T/api/rules/README.md`, + `$T/README.md` (раздел «Парсер форм»), `$T/CLAUDE.md` (раздел Среза 2b → «Формы через arm_statements»: модули адаптера, + словари форм в пакете, контракт 2.0, OSV_*, правило «копию не править»), `$T/api/tests/synthetic/generate_statements.py` + (импорты) + +- [ ] **Step 1: Удалить файлы и ссылки**; затем + +Run: `cd $T/api && grep -rnE "statement_(headers|periods|checks)|statements\.(checks|columns|detect|header|lines|merge|statement)\b|StatementParser|duplicate_line_codes" src tests rules scripts` +Expected: пусто (кроме `tests/synthetic/generate_statements.py`, если он ссылается на имена файлов фикстур — тогда не пусто +только там и это не импорт). + +- [ ] **Step 2: Run tests** + +Run: `cd $T && make check && make golden-check` +Expected: зелёный; `golden-check` — 0 (удаление не меняет вывод); `test_no_coordinates.py` и покрытие словарей ОСВ проходят. + +- [ ] **Step 3: Commit** + +```bash +git -C $T add -A api README.md CLAUDE.md +git -C $T commit -m "refactor: удалён собственный конвейер форм и словари форм — шаги 2–8 в arm_statements (#<issue>)" +``` + +--- + +### Task 10: Полная проверка, PR tabular-parser и fixtures + +Модель исполнителя: sonnet. Перед задачей — финальное ревью ветки (opus) по SDD. + +- [ ] **Step 1: Полная проверка** + +Run: `cd $T && make check && make check-full && make statements-check; echo "exit=$?"; cd $T && openspec validate unified-normalization-tp --strict` +Expected: всё зелёное; `statements-check` — `0` или `10` («копия отстала» — вписать в описание PR); change валиден. + +- [ ] **Step 2: Push и PR** (две ветки, два PR, взаимные ссылки) + +```bash +git -C $F push -u origin feat/<fx-issue>-statements-v2-golden +tea pulls create --login home --repo RSHB/arm-ka-fixtures --head feat/<fx-issue>-statements-v2-golden --base main \ + --title "Golden ОСВ и форм под контракт v2.0" --description "<closes #<fx-issue>; парный PR arm-tabular-parser>" +git -C $T push -u origin feat/<issue>-statements-v2 +tea pulls create --login home --repo RSHB/arm-tabular-parser --head feat/<issue>-statements-v2 --base main \ + --title "Формы через arm_statements, контракт v2.0" --description "<см. ниже>" +``` + + Описание PR tabular-parser: цель; ссылки на arm-ka-docs#116, этот план, парный PR fixtures; **условие слияния** — ядро с + приёмом 2.0 выложено (`proposal.md`, шаг 5), docs #117 слит, PR fixtures сливается сразу вслед; изменения манифеста с + причинами (задача 8); доля `done` на реальных формах до/после; «Rulings I made» (P4-n и R-n контроллера); таблица + находок с маркерами и статусами; `deferred` — отдельным списком. +- [ ] **Step 3: Решения в design.md** — контроллер переносит P4-n и свои R-n в + `openspec/changes/unified-statement-normalization/design.md` docs (`## Decisions made without discussion`, дата) — в + ветку `feat/116-unified-normalization`, если PR #117 не слит, иначе отдельным PR docs. + +--- + +## Решения автора плана без обсуждения (P4-n) + +Контроллер переносит их в `design.md` docs, раздел `## Decisions made without discussion` (дата 18.10.2026). + +- **P4-1.** Переход на общий код и на контракт v2.0 — одна задача (7): модели, копия схем, сборка книги, путь ОСВ и + воркер меняются вместе; «контракт v2.0 в копии» отдельной последней задачей не вынесен — модели 2.0 без копии 2.0 (или + наоборот) валят `result_contract` и тест идентичности. Golden и манифест — сразу за переключением (задача 8), до + удаления старого кода; между задачами 7 и 8 проверка идёт с `ARM_KA_FIXTURES=/nonexistent`. Цена: одна крупная задача и + окно в одну задачу без проверки на реальных файлах. +- **P4-2.** Адаптер пишется новыми модулями рядом со старыми (`cells`, `forms`, `header_cells`, `adapter`, `contract`, + `pipeline`); старые `statements/*` не правятся и удаляются целиком задачей 9. Цена: временное дублирование логики + поиска блоков и шапки. +- **P4-3.** `Cell.text` числа Excel — как в русской локали: группы по три через пробел, если в формате есть разделитель + групп; десятичная запятая; литералы секции (скобки) сохраняются; «-» добавляется только у односекционного формата; + `General`/неразобранный формат/xlsb — число без групп и экспоненты, целое без дробной части; дата — `dd.mm.yyyy`; + логическое — «ИСТИНА»/«ЛОЖЬ». Цена: у экзотических форматов текст может отличаться от экрана Excel; `value` и статус + от этого не зависят (у Excel значение берётся из `value`). Совпадает с примером docs + `result-statement-excel.json` (`"586 260,26"`). +- **P4-4.** Показываемое число вне `numeric(18,2)` больше не делает формат «неразобранным»: адаптер отдаёт `Decimal`, а + `VALUE_OUT_OF_RANGE` и `amount = null` выдаёт пакет (спека «Шаг 4»). Цена: правка поведения #42 для чисел больше 16 + целых цифр (на реальных файлах их нет). +- **P4-5.** Сетка кандидата: строки блока целиком, включая пустые; объединённая ячейка — только в позиции левой верхней, + прочие позиции `None`; ширина — наибольшая среди листов кандидата. Цена: подпись, объединённая на два столбца, видна + пакету только в левом. +- **P4-6.** Шапка ищется в строках блока выше первой строки с кодом его столбца кодов (столбец — `code_column` пакета: + то же правило (а)/(б) шага 2 с защитой от блока «Коды», своего правила у адаптера нет), без кодов — во всём блоке: так титульный блок «Коды» (ОКОПФ 12267) не мешает, а подписи под формой (ИНН и ФИО в блоке + подписей) не попадают в шапку. Цена: метка шапки ниже начала таблицы не найдётся. +- **P4-7.** Шаблоны словаря пакета адаптер применяет к тексту, нормализованному функцией пакета по таблице «Словари» + раздела API плана 1: метки шапки, заголовки, ОКУД, `auditor`, `legal_form` — `normalize_label` (= прежний + `normalize_text` tabular-parser, P1-22); подпись «Код» и относительные периоды — `normalize_caption`; маркер + продолжения — `is_continuation`. Цена: нет — смена нормализации в пакете меняет и `type_hint`, и адаптер. +- **P4-8.** Склейка: открытая форма — последний кандидат с `type_hint` `BALANCE`/`INCOME`; лист с маркером сверяется только + по типу; лист без маркера (сильное продолжение — столбец кодов по правилу шага 2 или своя строка заголовка; слабое — + столбец кодов ослабленным поиском `code_column(…, relaxed=<тип формы>)`, минимум `min_codes_continuation` кодов, + сразу после листа формы) — `sheet_joins` с числом колонок сумм `len(amount_columns(...))` пакета («столбцы правее + столбца кодов, где в строках с кодами есть число»); открытой формы нет — отдельный кандидат без предупреждения (пакет даст `NO_ORGANIZATION_NO_INN`). + Цена: «Изменение, %» с числами считается колонкой сумм с обеих сторон сравнения; лист с иной шапкой, но тем же числом + колонок, присоединится и будет сопоставлен пакетом по датам. +- **P4-9.** `SHEET_OTHER` для листов «прочее» выдаёт книга сама (документа и кандидата нет), а не пакет по кандидату без + `type_hint`. Цена: два места порождения одного кода (у пакета — для кандидатов, не нашедших ни типа, ни кодов). +- **P4-10.** `XLSB_NO_FORMATS` — у каждого кандидата книги xlsb (P1-5); `NUMBER_FORMAT_UNPARSED` — одно на кандидата с + перечнем форматов всех его листов. Цена: у пояснений xlsb тоже есть `XLSB_NO_FORMATS` (`info`). +- **P4-11.** Книга вызывает `normalize_candidate` по одному кандидату, а не `normalize`: так проверяется дедлайн между + кандидатами (`header.form_name` пакет ставит сам из `evidence.title`, пара «кандидат → документ» не нужна). + Исключение пакета на кандидате — `RECOGNITION_FAILED` всей книги, как сейчас (`TABLE_PARSE_ERROR` — код таблицы ИС). + Цена: одна сломанная форма роняет книгу с ОСВ. +- **P4-12.** Коды ОСВ — по источнику предупреждения, не по тексту: `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`, `OSV_HEADER`, + `OSV_ROWS`, `OSV_INN_ROWS`, `OSV_NO_DATA_ROWS`, `OSV_BALANCE_IDENTITY`, `OSV_PARENT_SUM`, `OSV_TOTAL`, + `OSV_XLSB_NO_OUTLINE`; тексты сообщений прежние, кроме иерархии. Цена: внутри `OSV_HEADER` и + `OSV_ROWS` разные случаи различаются только текстом. +- **P4-13.** Заметки читателя: несовпадение формата — `FILE_TYPE_MISMATCH` файла; «xls без кодовой страницы» — + `XLS_NO_CODEPAGE` файла (`info`, код пакета, P1-23) в любой книге xls — касается и форм, и ОСВ; «xlsb без outline» — в + каждый документ ОСВ книги (`OSV_XLSB_NO_OUTLINE`), в книге без ОСВ только логируется (иерархия строк — только у ОСВ). + Цена: предупреждение о кодировке видно в карточке файла, а не у листа ОСВ. +- **P4-14.** Предупреждение «форма не разобрана: книга завершилась ошибкой листа ОСВ» снято: в v2.0 для него нет кода, а + текст ошибки и так называет лист ОСВ. Цена: аналитик не видит списка листов форм, оставшихся неразобранными. +- **P4-15.** Статус документа ОСВ — прежние условия листа: контрольные суммы §1.4 сошлись и строки ИНН без нарушений + (`glue.ok`, #35). Цена: формулировка спеки «когда контрольные суммы сошлись» читается шире. +- **P4-16.** Нет `api/arm_statements/VERSION` — сервис не стартует (`RuntimeError`), как при ошибке словаря; в golden + `service.statements_version` — volatile-поле. Цена: копия без `VERSION` не запустится даже локально. +- **P4-17.** Новые синтетические случаи (форма xlsb, книга из пояснений, конфликты продолжений) — в памяти в юнит-тестах + (`sheet_from_rows`, `write_xlsb`), новых файлов в `fixtures/statements-xlsx/synthetic` нет; PR fixtures — только golden. +- **P4-18.** Манифест получает необязательное поле `error_warnings` (точное множество кодов `error` документа); + проверка уникальности кодов строк снята (повторы законны, Р-4). Цена: при правке пакета, меняющей набор + предупреждений, манифест правится вместе с golden. +- **P4-19.** Тест покрытия словарей tabular-parser больше не охватывает словари форм — они в пакете. Цена: «мёртвый» + синоним заголовка или метки шапки на реальных файлах не ловится (O-8). +- **P4-20.** В tabular-parser — свой change `unified-normalization-tp` с дельтами `statement-parsing`, + `tabular-recognition-service`, `osv-parsing` (по образцу P0-5); требование «Копия контракта идентична источнику» правит + план 0, здесь не трогается. +- **P4-21.** Golden — отдельные issue, ветка и PR в RSHB/arm-ka-fixtures (один PR на репозиторий); сливается сразу за PR + tabular-parser. Цена: короткое окно, когда `make check` `main` одного из них красный на golden. +- **P4-22.** Две формы на одном листе дают два документа с одинаковым `locator` (спека уникальности `locator` не требует; + `ref` строк различаются). + +## Чего не хватает в API пакета для адаптера Excel (O-n) + +Пункты O-1…O-7 и O-9 закрыты сведением с планом 1 (18.10.2026, его P1-21…P1-31); маркеры сохранены. + +- **O-1.** Закрыт: `code_column(rows, rules, *, relaxed)` и `amount_columns(rows, code_col, rules, *, source) -> + tuple[int, ...]` в API (P1-28); адаптер зовёт их через `sheet_code_column` / `sheet_amount_columns` (задача 4), своего + правила (б) нет. +- **O-2.** Закрыт: `normalize_label` и `normalize_caption` в API, таблица «ключ словаря → нормализация» (P1-22); P4-7. +- **O-3.** Закрыт: `StatementHeader.form_name` в пакете (P1-29); адаптер `form_name` не ставит. +- **O-4.** Закрыт: `is_continuation(text, rules) -> bool` в API; `continuation_marker` на нём (задача 4). +- **O-5.** Закрыт: код `XLS_NO_CODEPAGE` (`info`, файл) в словаре пакета, таблице спеки и §11.9 (P1-23); P4-13. +- **O-6.** Закрыт: ключи `Rules.headers.labels` перечислены в API плана 1 (`LABEL_KEYS`), загрузчик пакета их проверяет. +- **O-7.** Закрыт: описка «плана 3» в плане 1 исправлена на «плана 4». +- **O-8.** Покрытие словарей форм реальными файлами теряется (P4-19): в пакете тесты только на синтетике. Предложение: + тест покрытия `Rules.headers.*` в tabular-parser на `statements-xlsx/**` (как покрытие словарей ОСВ) — follow-up. +- **O-9.** Закрыт: `min_codes_continuation: 1` — в `statement_code_ranges.yaml` пакета (`Rules.code_ranges`), им + пользуются ослабленный поиск `code_column` и `sheet_joins` (P1-28). +- **O-10.** Сверено с планом 2 (появился в ветке во время написания): подготовку хоста делает его задача 8, задача 2 + этого плана сведена к сверке и `--in-place`; `.dockerignore` не меняется (план 2: «`api/arm_statements` не добавлять в + `.dockerignore`» — тесты копии попадают в образ, это его решение). Пин `pytest` план 2 не вводит — здесь тоже нет. + +## Открытые вопросы владельцу + +- **В-1.** Гранулярность кодов `OSV_*` (P4-12): хватает ли кодов по источнику, или экрану нужны отдельные коды для + случаев внутри `OSV_HEADER` (организация, счёт, период, валюта) и `OSV_ROWS`? Ответ меняет только `osv_warnings.py` и + golden. +- **В-2.** Межениновская и НПЗ должны остаться `done` (спека манифеста). Если прогон задачи 8 покажет, что подписи под + формой Межениновской или лист 4 НПЗ дают `missing`/`unreadable` или `SEGMENT_*` по правилам шагов 2–3 пакета — это + противоречие спеки и реального файла: решение (правка пакета или манифеста) — за владельцем, адаптер под файл не + подгоняется. +- **В-3.** Слияние: PR tabular-parser и PR fixtures — после выкладки ядра с приёмом 2.0 (части А и Б плана 3) и слияния + docs #117; порядок относительно scan-gateway свободный. Подтвердить, что до этого PR остаётся открытым, а не + сливается с флагом. diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md new file mode 100644 index 0000000..9625e05 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md @@ -0,0 +1,1137 @@ +# План 5 — scan-gateway: адаптер OCR → `arm_statements`, контракт результата 2.0. План реализации + +> **Для исполнителей-агентов:** обязательный навык — superpowers:subagent-driven-development (в плане 8 задач: по +> `~/.claude/CLAUDE.md` — SDD, контроллер — фоновый сабагент на модели sonnet). Модели исполнителей: задачи 0, 2, 3, 4, +> 6, 7 — sonnet (готового кода в плане нет, есть сигнатуры, тесты и правила спеки); задачи 1 и 5 — haiku (проверки и +> удаление); финальное ревью ветки — opus. Шаги размечены чекбоксами (`- [ ]`). + +**Goal:** scan-gateway разбирает набор json ИС Сканирование адаптером шага 1 (одна таблица ИС — один `FormCandidate`) и +копией общего пакета `arm_statements` (шаги 2–8), пишет файл результата и конверт по контракту v2.0 со структурными +предупреждениями файла (`JSON_FAILED`, `TABLE_PARSE_ERROR`, `TABLE_LOST`, `TABLE_SKIPPED`); собственный нормализатор +шагов 2–8 (`normalizer/ocr/text|dates|amounts|layout|rows|header|document`), `normalizer/rules.py` и `api/rules/*.yaml` +удалены; golden `fixtures/ocr/expected_normalize/*` пересобраны. + +**Architecture:** обмен с ИС (горячие папки, опрос, settle, склейка `raw/{job_id}.json`, очистка) не меняется. Новый +нормализатор — три слоя чистых функций: `normalizer/files.py` (ошибка json набора), `normalizer/ocr/adapter.py` +(таблица json → `FormCandidate`), `normalizer/ocr/answer_set.py` (обход склейки: `JSON_FAILED`, `TABLE_PARSE_ERROR`, +`normalize_candidate` на каждую таблицу, `envelope_status`, выбор кода ошибки) — всё в типах пакета. Граница с +контрактом — функции пакета `to_contract` / `warning_to_contract` (документ и предупреждение → `dict` формы v2.0, +валидируется pydantic-моделями контракта шлюза) в `OcrNormalizer.normalize` → `NormalizeOutcome`, из которого воркер, как +и прежде, собирает файл результата и конверт. Своего конвертера (`contract_map`) у шлюза нет — он общий с tabular-parser +(план 1, P1-26). + +**Tech Stack:** Python 3.11, FastAPI, pydantic v2, PyYAML (через пакет), pytest 9.1.1, ruff 0.16.10, mypy 2.4.0, jsonschema +4.26.0; `tea` (Gitea), OpenSpec, codegraph, git worktree. + +**Spec:** `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/` +(ветка `feat/116-unified-normalization` репозитория arm-ka-docs): главное — `specs/scan-gateway/spec.md`; ещё +`specs/statement-normalization/spec.md` («Шаг 1 — кандидат в форму», «Шаг 1 — шапка кандидата», «Очистка OCR-текста», +«Шаг 8 — предупреждения», «Шаг 8 — статус документа и конверта»), `specs/recognition-contract/spec.md` («Сообщение +результата распознавания», «Файл результата», «Версионирование контракта», «Копия контракта…», «Переход на контракт +v2.0 — «сначала ядро»»), `specs/quality-checks/spec.md`, `proposal.md` («Порядок выкладки», шаг 5; «Чего ждать на +реальных сканах»); контракт — `docs/contracts/*.schema.json` v2.0 и `docs/contracts/examples/`; правила — +`docs/context/05-domain-rules.md` §11.1 (адаптер, ошибки набора), §11.9 (таблица предупреждений, статус). Публичный API +пакета — `plans/2026-10-18-plan-1-arm-statements.md`, раздел «Публичный API пакета» (только эти имена). + +--- + +## Место в работе и порядок + +| План | Что | Связь с этим планом | +|---|---|---| +| 0 | тест идентичности копий контракта по MAJOR (в том числе scan-gateway) | слит в scan-gateway `main` до задачи 4; тест идентичности здесь не меняется | +| 1 | пакет `arm_statements` (репозиторий `arm-statements`) | API — раздел «Публичный API пакета»; правила разбора OCR (`test_parses_real_forms`, двойники, правки sg#40) — в пакете | +| 2 | `sync-statements`: копия пакета в `api/arm_statements/`, `statements-check` в `make check-full` | PR задачи 9 плана 2 (`feat/<issue>-arm-statements-copy`) слит в scan-gateway `main` до задачи 1; задача 1 сверяет факт | +| docs PR #117 | контракт v2.0, правила §11 | слит в docs `main` до задачи 4 | +| 3 | arm-core: приём `2.0` (части А и Б) | **выложено на контур до слияния PR этого плана** («сначала ядро», `proposal.md` шаг 3) | +| 4 | tabular-parser на 2.0 | независим (шаг 5 — в любом порядке) | + +PR этого плана можно открыть и пройти ревью раньше, но **слияние** — только по команде человека и только после выкладки +ядра, принимающего `2.0`: первый же `line_code = null` ядру, знающему только `1.x`, даёт файлу `ERROR`. Golden в fixtures +— отдельная ветка и PR; он сливается **первым**, PR scan-gateway — сразу за ним (между слияниями тест golden в `main` +scan-gateway красный на машине с fixtures — окно держать минимальным). + +## Разбивка + +| | scan-gateway | fixtures | +|---|---|---| +| Issue | RSHB/arm-scan-gateway: «Единая нормализация ББ/ОФР: адаптер OCR → arm_statements, контракт результата 2.0 (arm-ka-docs#116)» | своей issue нет — PR ссылается на issue scan-gateway (как fixtures PR #23 → scan-gateway#39) | +| Ветка | `feat/<issue>-statements-v2` | `feat/<issue sg>-golden-statements-v2` | +| Worktree | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-sg/scan-gateway` | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-sg/fixtures` | +| Change OpenSpec | `openspec/changes/statements-v2/` (capability `image-recognition-gateway`) | — | +| Слияние | вторым, после выкладки ядра | первым | + +В тексте плана `$WS` = `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-sg`, `$WT` = `$WS/scan-gateway`, +`$FX` = `$WS/fixtures`. Команды `make` — из `$WT`, `pytest` — из `$WT/api`, git — `git -C <абсолютный путь>`. + +## Global Constraints + +- Python 3.11; конфиг ruff и mypy — существующий `api/pyproject.toml` scan-gateway (строгие overrides на + `src.normalizer.*`, `src.contract.*`, `src.core.worker`); новые модули `src/normalizer/**` под них попадают. +- Копию `api/arm_statements/` **не править** (её раскладывает `sync-statements`, план 2; прямая правка — код 20 + `statements-check`). Импорт пакета — только `from arm_statements import …` и только имена раздела «Публичный API + пакета» плана 1; `import arm_statements` — только в `src/normalizer/**` и `tests/**`. +- Адаптер (спека `scan-gateway`, «Адаптер json ИС шага 1», дословно): кандидат на **каждую** таблицу каждого успешного + json; шапка json (`organization`, `inn`, `units`, `date`) копируется в шапку **каждой** своей таблицы как `Cell` + (`text = form_name`, `confidence` ИС); `label.form_name` таблицы — заголовок в `evidence`; сетка — плотная по + `row_index` и `column_index` ИС, пустая позиция — `None`; `Cell.ref = row:<row_index>`; `locator = + file:<имя json>;table:<i>`; `value` у OCR-ячеек всегда `None`; `source = Source.OCR`; `segment_breaks = ()`. +- `type_hint` — только функцией пакета `type_hint(okud_text, title_text, rules)` (у OCR `okud_text = None`). +- Предупреждения файла: `JSON_FAILED` (`error`, `locator = file:<имя json>`, `message = "<код ИС>: <сообщение ИС>"`), + `TABLE_PARSE_ERROR` (`error`, `locator = file:<имя json>;table:<i>`, документа у таблицы нет); `TABLE_LOST` / + `TABLE_SKIPPED` выдаёт пакет. Хост создаёт предупреждения **только** через `make_warning` пакета с членом + `WarningCode` (`WarningCode.JSON_FAILED`, `WarningCode.TABLE_PARSE_ERROR`), не строковым литералом (уровень — из + словаря пакета; неизвестный код — `KeyError`). +- Статус конверта — только `envelope_status` пакета; документов нет → `error`: все json ошибочны — код и сообщение + первого ошибочного json; есть успешный json — `RECOGNITION_FAILED` с сообщением `ни одной таблицы не разобрано`. +- Контракт: файл результата и конверт (включая ack `processing`) — `schema_version = "2.0"`, + `result_schema_version = "2.0"`; запрос `recognition-request` остаётся `1.x` (запрос `2.0` → `SCHEMA_MISMATCH`, как + сейчас). `service.statements_version` — содержимое `api/arm_statements/VERSION` — в файле результата; в конверте + `service` — `{name, version}`. `metrics.warnings` конверта — те же структуры, что `metrics.warnings` файла результата. +- Функции `normalizer/files.py`, `normalizer/ocr/adapter.py`, `normalizer/ocr/answer_set.py` — чистые: без I/O, без + настроек, без логов (как `ocr/*` сейчас). Перевод в контракт — только `to_contract` / `warning_to_contract` пакета; + своего уплощения шапки и отбора ключей предупреждений шлюз не пишет. +- Горячие папки, settle, склейка (`gateway/merge.py`), очистка, таймауты, эмулятор — **не меняются**. +- Тест идентичности контракта (`api/tests/unit/test_contract.py::test_vendored_contracts_are_identical_to_docs` и + `tests/contract_identity.py`) — правило плана 0; в этом плане не меняется. +- `make check` (без Docker) зелёный после каждой задачи; перед PR — `make check-full` (`make up` из `../docs/dev/`) с + `statements-check`; код `10` («копия отстала») — зелёный, вписывается в описание PR. +- Язык комментариев, docstring, сообщений, коммитов, issue, PR — русский; идентификаторы — английские. Docstring нового + модуля называет требование спеки или § правил. +- Коммиты заканчиваются строками `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>` и + `Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W`; описание PR — строкой + `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. Слияние — только по команде человека. +- Находки и решения — с маркерами (`T<n>.<k>`, `C-n`/`I-n`/`M-n`, `R-n`, `O-n`); решения этого плана — `P5-n` (раздел в + конце); исполнитель задачи 0 переносит их в `design.md` change'а `statements-v2` (`## Decisions made without + discussion`), контроллер — в `design.md` docs-change'а. +- Если утверждение теста этого плана противоречит спеке — источник истины спека; если реальный json ведёт себя не так, + как ждёт Review Focus, а адаптер построил кандидата верно, — это находка для пакета (план 1), а не повод править копию + или подгонять адаптер. + +## Review Focus + +Реальные ответы ИС из `fixtures/ocr/`, на которых сойдутся все правила сразу. Каждый пункт закреплён тестом задачи 6 +(`api/tests/unit/normalizer/test_real_answers.py`, маркер `fixtures`); набор json собирается как в golden +(`tests.golden_update.real_set`, имена `<JOB>.json`, `<JOB>_<суффикс>.json`): + +1. **aep_3 (ББ + ОФР, зашумлённый скан без строки заголовка)** — колонки сетки 2, 4, 5 (ИС пропускает 3); ББ: `row:0` + «НИ» (без цифр) с суммой → `missing`; `row:2` «изо :» → `detail` с `parent_ref = "row:1"`; `row:3` «| 140» → `ok` + 1140; `row:12` «12 /» и `row:31` «420» → `unreadable`; `row:17` «БАЛАНС» без кода → `detail` под `row:16`; ни одна + строка с содержимым не пропала, `ref` уникальны. ОФР: `row:0` «Выручка» без кода, в колонке 2 `211(1 |` → `missing`, + `amount = null`, `string_value = "211(1 |"`, `is_recognized = false`; `row:1` 2120 `(7744536}` → `amount = + "-7744536"`, `is_recognized = false`; `row:8` `{38378)` → `amount = null`. Оба документа `needs_check`. +2. **aep_6 (чистый скан, повтор шапки)** — строка `row:6` с кодом `"44 30\nI |"` → одна строка `unreadable` + (`line_code = null`, `line_code_text` сырой, без `#1`/`#2`, значения пустые); повторы 1220 и 1520 в ББ и 2410 в ОФР — + все строки `ok`; ячейка кода `}` (ОФР `row:4`) — пустая, строка `detail`; ББ `needs_check`. +3. **«БО 1 кв 2025» и «Бухгалтерская отчетность 1 кв.25»** — склейка `"1500\n1700"` (`row:73` и `row:93`) → строки + `row:<i>#1` 1500 и `row:<i>#2` 1700 с суммами 43274 / 198622 и 917775 / 2371333 в колонке 2, `ROW_SPLIT` (`info`); + повторы 1220 и 1520 — по две строки `ok`; у «БО» `date.form_name` — название организации (дата отчёта не разобрана, + период — по колонкам); ожидание по Р-23 — все четыре документа `done`, конверт `done` (P5-17). +4. **Набор с ошибочным json** — aep_3 + `aep_3_ofr` (`INVALID_DOCUMENT`): документы из `aep_3.json`, `JSON_FAILED` + (`error`, `locator = file:<JOB>_ofr.json`, `message = "INVALID_DOCUMENT: Не удалось определить тип документа."`), + конверт `needs_check`; aep_5 (единственный json с ошибкой) → конверт `error` `INVALID_DOCUMENT` с сообщением ИС, + документов нет. +5. **Форма 4 без таблиц (aep_8_form4)** — json с `INVALID_DOCUMENT` и пустым `table_structure`: конверт `error` + `INVALID_DOCUMENT`, документов нет, исключений нет. + +--- + +## Структура файлов + +``` +scan-gateway/ +├── openspec/changes/statements-v2/ # proposal.md, tasks.md, design.md, specs/image-recognition-gateway/spec.md +└── api/ + ├── arm_statements/ # копия пакета (план 2) — НЕ ПРАВИТЬ + ├── contracts/ # копии схем и примеров v2.0 (запрос — v1.0) + ├── rules/ # УДАЛЯЕТСЯ целиком (задача 5) + ├── src/ + │ ├── contract/{common,request,message,result}.py # модели v2.0 (задача 4) + │ ├── core/worker.py # build_result(), метрики-структуры (задача 4) + │ ├── app.py # OcrNormalizer(default_rules()) (задача 4) + │ └── normalizer/ + │ ├── __init__.py # Normalizer, NormalizeOutcome v2, NullNormalizer + │ ├── files.py # FileError, file_error() — ошибки json набора (задача 3) + │ ├── rules.py # УДАЛЯЕТСЯ (задача 5) + │ └── ocr/ + │ ├── __init__.py # OcrNormalizer (задача 4) + │ ├── adapter.py # header_cells(), table_candidate() (задача 2) + │ ├── answer_set.py # normalize_answer_set() (задача 3) + │ └── text|dates|amounts|layout|rows|header|document.py # УДАЛЯЮТСЯ (задачи 4–5) + └── tests/ + ├── golden_update.py # формат golden v2 (задача 6) + └── unit/normalizer/ + ├── test_adapter.py # задача 2 + ├── test_answer_set.py # задача 3 (в том числе сквозной тест дат) + ├── test_contract_boundary.py # задача 4: to_contract пакета → модели и схема v2.0 шлюза + ├── test_ocr_normalizer.py # переписан (задача 4) + ├── test_golden.py # задача 6 + ├── test_real_answers.py # Review Focus (задача 6) + └── test_text|dates|amounts|layout|rows|header|document|rules.py # УДАЛЯЮТСЯ (задачи 4–5) +``` + +--- + +### Task 0: Workspace, issue, ветки, change OpenSpec + +**Files:** +- Create: `$WT/openspec/changes/statements-v2/{proposal.md,tasks.md,design.md,specs/image-recognition-gateway/spec.md}` + +**Interfaces:** +- Consumes: — +- Produces: worktree `$WT` на `feat/<issue>-statements-v2`, worktree `$FX` на `feat/<issue>-golden-statements-v2`, + отсоединённый `$WS/docs`, симлинки; change `statements-v2`, который проходит `openspec validate --strict`. + +- [ ] **Step 1: Предусловия.** Проверить и при невыполнении остановиться с отчётом контроллеру (не обходить): + +```bash +WS0=/Users/andrey.epifancev/projects/awp-ca +git -C $WS0/docs fetch origin && git -C $WS0/docs show origin/main:contracts/recognition-result.schema.json | grep -q '"arm-ka/recognition-result/v2"' # docs PR #117 слит +git -C $WS0/scan-gateway fetch origin && git -C $WS0/scan-gateway show origin/main:api/tests/contract_identity.py >/dev/null # план 0 слит +git -C $WS0/scan-gateway show origin/main:api/arm_statements/VERSION # план 2 слит (задача 1) +test -d $WS0/arm-statements/.git # источник для statements-check +``` + +- [ ] **Step 2: Зеркальный workspace и worktree** (порядок `~/.claude/CLAUDE.md`: worktree на временной ветке → issue → + переименование): + +```bash +WS=/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-sg +mkdir -p $WS +git -C $WS0/scan-gateway worktree add -b tmp/statements-v2 $WS/scan-gateway origin/main +git -C $WS0/docs worktree add --detach $WS/docs origin/main +git -C $WS0/fixtures fetch origin && git -C $WS0/fixtures worktree add -b tmp/golden-statements-v2 $WS/fixtures origin/main +for l in reference-old-arm arm-statements CLAUDE.md .claude; do ln -s $WS0/$l $WS/$l; done +tea issues create --login home --repo RSHB/arm-scan-gateway \ + --title "Единая нормализация ББ/ОФР: адаптер OCR → arm_statements, контракт результата 2.0 (arm-ka-docs#116)" \ + --description "<цель из Goal; ссылки: RSHB/arm-ka-docs#116, план openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-5-scan-gateway.md>" +git -C $WS/scan-gateway branch -m feat/<issue>-statements-v2 +git -C $WS/fixtures branch -m feat/<issue>-golden-statements-v2 +cd $WS/scan-gateway && codegraph init && make venv +``` + +Все вызовы `codegraph_explore` дальше — с `projectPath` = `$WT`. + +- [ ] **Step 3: Change OpenSpec `statements-v2`.** `proposal.md` — зачем (ссылка на docs-change и спеку `scan-gateway`), + что меняется (по Goal), порядок выкладки (шаг 5, слияние после ядра; fixtures первым). `specs/image-recognition-gateway/spec.md` + — дельта к `openspec/specs/image-recognition-gateway/spec.md`: + - `MODIFIED` «Результат по контракту» — v2.0: `schema_version` `2.0` у конверта и файла, структурные + `metrics.warnings`, `JSON_FAILED`, `service.statements_version`; сценарии «Ошибка одного из документов» и «Все + документы ошибочны» — по спеке `scan-gateway` «Ошибки набора json»; + - `MODIFIED` «Нормализация ответа ИС в `statementDocument`» — заменяется требованием адаптера (текст требования «Адаптер + json ИС шага 1» docs-спеки `scan-gateway` и сценарий «Один json — ББ и ОФР»), шаги 2–8 — копия `arm_statements`; + - `MODIFIED` «Статус результата нормализации» — `envelope_status`, `TABLE_PARSE_ERROR`, `RECOGNITION_FAILED`; + - `REMOVED` «Справочники нормализации — данные с редакциями форм» (Reason: справочник строк удалён, двойники и + заголовки — словари пакета; Migration: `api/rules/` и `SCAN_RULES_DIR` удалены). + `tasks.md` — задачи 1–7 этого плана чекбоксами. `design.md` — ссылка на docs `design.md` и раздел `## Decisions made + without discussion` с P5-1…P5-19 (дата 18.10.2026, решение, обоснование, цена — из раздела плана). + +- [ ] **Step 4: Validate** + +Run: `cd $WT && openspec validate statements-v2 --strict` +Expected: `Change 'statements-v2' is valid` + +- [ ] **Step 5: Commit** + +```bash +git -C $WT add openspec/changes/statements-v2 +git -C $WT commit -m "openspec: change statements-v2 — адаптер OCR → arm_statements, контракт 2.0 (#<issue>, arm-ka-docs#116)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> +Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" +``` + +--- + +### Task 1: Подготовка хоста по плану 2 (сверить с планом 2) + +**Files:** — (только проверка; правок нет, если план 2 выполнен) + +**Interfaces:** +- Consumes: результат плана 2 в `main` scan-gateway. +- Produces: подтверждённые факты для задач 2–7: `import arm_statements` работает из `api/`, `make check` гоняет тесты и + `mypy` копии, `make statements-check` есть и входит в `make check-full`. + +> Подключение копии — задача 9 плана 2 (`plans/2026-10-18-plan-2-sync-statements.md`, раздел «Что должны включить +> планы 3, 4, 5»): `lint` — `mypy src arm_statements`, `testpaths` — `tests` и `arm_statements/tests`, `arm_statements` +> в строгом блоке mypy и `known-first-party`, `statements-check` в `check-full`. Если копии в `main` нет — **стоп**, +> задача заблокирована, отчёт контроллеру (первичную раскладку в этой ветке не делать). Расхождение факта с планом 2 — +> `O-n` в отчёте. + +- [ ] **Step 1: Копия и маркер версии** + +Run: `cd $WT/api && test -f arm_statements/VERSION && test -d arm_statements/tests && test -f arm_statements/rules/ocr_lookalikes.yaml && .venv/bin/python -c "import arm_statements as a; print(a.statements_version(), a.default_rules() is not None)"` +Expected: полный хэш коммита и `True`. + +- [ ] **Step 2: `make check` охватывает копию** + +Run: `cd $WT && grep -n "mypy src arm_statements" Makefile && grep -n "arm_statements" api/pytest.ini && make check` +Expected: обе строки найдены; `make check` зелёный, в выводе pytest есть `arm_statements/tests/`. + +- [ ] **Step 3: `statements-check` и `check-full`** + +Run: `cd $WT && grep -n "statements-check" Makefile && make statements-check; echo "код $?"` +Expected: цель есть и вызывается из `check-full`; код `0` (или `10` — запомнить для описания PR). + +- [ ] **Step 4: Новая версия пакета (только если нужна).** Если задачам 2–6 нужна версия `arm-statements` новее, + чем в `main` (например, исправление по находке Review Focus), — по плану 2: `cd $WT && ../docs/scripts/sync-statements.sh + --in-place --host-dir .`, копия — отдельным коммитом `chore(arm_statements): копия пакета arm-statements@<v7> + (#<issue>)`; руками копию не править. + +- [ ] **Step 5: conftest хоста.** `grep -n "autouse" $WT/api/tests/conftest.py $WT/api/tests/unit/conftest.py` — каждая + `autouse`-фикстура ограничена каталогом `tests/` хоста (спека: иначе она задевает тесты копии). Нарушение — находка + `O-n` плану 2, не правка в этой ветке. + +--- + +### Task 2: Адаптер — таблица json ИС → `FormCandidate` + +**Files:** +- Create: `$WT/api/src/normalizer/ocr/adapter.py` +- Test: `$WT/api/tests/unit/normalizer/test_adapter.py` + +**Interfaces:** +- Consumes: из пакета — `Cell`, `HeaderCells`, `Evidence`, `FormCandidate`, `Source`, `Rules`, `type_hint`. +- Produces (`src/normalizer/ocr/adapter.py`): + - `class TableStructureError(ValueError)` — нарушена структура таблицы ИС (P5-2) + - `confidence_of(raw: object) -> float` — число (не `bool`) → в `[0, 100]`; NaN, бесконечность, не число, + отсутствие → `0.0` (P5-3) + - `header_cells(data_ocr: Mapping[str, object]) -> HeaderCells` — `organization` → `organization_name`, `inn` → `inn`, + `units` → `units`, `date` → `report_date`; каждое — `Cell(form_name, None, confidence_of(confidence), + "data_ocr.<поле ИС>")`; поля нет, не объект, `form_name` не строка или пустой после `strip()` → `None`; остальные + шесть полей — `None` (P5-5) + - `table_candidate(file_name: str, index: int, table: object, header: HeaderCells, rules: Rules) -> FormCandidate` + — по Global Constraints; строки сетки — строки ИС по возрастанию `row_index`, по одной строке сетки на строку ИС, + пропуски `row_index` не заполняются; ширина каждой строки сетки — `max(column_index) + 1` по таблице; ячейка — + `Cell(text, None, confidence_of(...), f"row:{row_index}")`, `text` — `value` как есть (`None` → `""`, не строка → + `str(value)`), без `strip`; позиция без ячейки ИС — `None`; `evidence = Evidence(okud=None, title=<Cell label или + None>)`, `title.ref = f"table:{index}.label"`; `type_hint = type_hint(None, <title.text или None>, rules)`. Бросает + `TableStructureError`, если: `table` не объект; `rows` не список; строка не объект или без целого `row_index` + (`bool` — не целое); `row_index` повторяется в таблице; `cells` не список; ячейка не объект, `column_index` не целое + или `< 0`; два `column_index` в одной строке. + +- [ ] **Step 1: Write the failing test** + +```python +import math + +import pytest +from arm_statements import Cell, DocumentType, Source, default_rules + +from src.normalizer.ocr.adapter import TableStructureError, confidence_of, header_cells, table_candidate + +RULES = default_rules() +DATA_OCR = { + "label": {"form_name": "Бухгалтерская отчетность", "confidence": 100.0}, + "organization": {"form_name": "ООО «Пример»", "confidence": 100.0}, + "inn": {"form_name": "3|1 21 2|", "confidence": 61.538464}, + "units": {"form_name": "тыс. руб.", "confidence": 100.0}, + "date": {"form_name": "", "confidence": 0.0}, +} + + +def row(index: object, *cells: tuple[object, object, object]) -> dict[str, object]: + return {"row_index": index, "cells": [{"column_index": c, "value": v, "confidence": p} for c, v, p in cells]} + + +def table(label: str, *rows: dict[str, object]) -> dict[str, object]: + return {"label": {"form_name": label, "confidence": 100.0}, "rows": list(rows)} + + +def test_header_is_copied_from_json() -> None: + h = header_cells(DATA_OCR) + assert h.organization_name == Cell("ООО «Пример»", None, 100.0, "data_ocr.organization") + assert h.inn == Cell("3|1 21 2|", None, 61.538464, "data_ocr.inn") + assert h.units == Cell("тыс. руб.", None, 100.0, "data_ocr.units") + assert (h.report_date, h.okud, h.okpo, h.okopf, h.okfs, h.okei, h.address) == (None,) * 7 + + +def test_grid_by_row_and_column_index() -> None: + t = table( + "Бухгалтерский баланс", + row(9, (0, "Итого", 100.0)), + row(8, (1, "[190 :", 25.0), (0, "Прочие", 100.0), (4, "", 0.0), (5, None, 100.0)), + ) + cand = table_candidate("j.json", 0, t, header_cells(DATA_OCR), RULES) + assert (cand.source, cand.locator, cand.type_hint) == (Source.OCR, "file:j.json;table:0", DocumentType.BALANCE) + assert cand.segment_breaks == () and cand.warnings == () + assert cand.evidence.okud is None + assert cand.evidence.title == Cell("Бухгалтерский баланс", None, 100.0, "table:0.label") + assert cand.rows == ( + (Cell("Прочие", None, 100.0, "row:8"), Cell("[190 :", None, 25.0, "row:8"), None, None, + Cell("", None, 0.0, "row:8"), Cell("", None, 100.0, "row:8")), + (Cell("Итого", None, 100.0, "row:9"), None, None, None, None, None), + ) + assert cand.header == header_cells(DATA_OCR) + + +def test_multiline_text_is_kept() -> None: + t = table("Бухгалтерский баланс", row(93, (1, "1500\n1700", 78.0), (2, "917 775\n2 371 333", 100.0))) + cand = table_candidate("j.json", 0, t, header_cells(DATA_OCR), RULES) + assert cand.rows[0][1] == Cell("1500\n1700", None, 78.0, "row:93") + + +def test_income_label_and_unknown_label() -> None: + income = table_candidate("j.json", 1, table("Отчет о финансовых результатах"), header_cells(DATA_OCR), RULES) + assert income.type_hint is DocumentType.INCOME and income.rows == () + blank = table_candidate("j.json", 2, {"label": {"form_name": " ", "confidence": 0.0}, "rows": []}, + header_cells(DATA_OCR), RULES) + assert (blank.evidence.title, blank.type_hint) == (None, None) + + +@pytest.mark.parametrize(("raw", "expected"), [(25, 25.0), (150.0, 100.0), (-3, 0.0), (math.nan, 0.0), + (math.inf, 0.0), ("100", 0.0), (True, 0.0), (None, 0.0)]) +def test_confidence_of(raw: object, expected: float) -> None: + assert confidence_of(raw) == expected + + +@pytest.mark.parametrize( + "broken", + [ + "не объект", + {"label": {}, "rows": "x"}, + table("ББ", {"cells": []}), + table("ББ", row(True, (0, "a", 100.0))), + table("ББ", row(1, (0, "a", 100.0)), row(1, (0, "b", 100.0))), + table("ББ", {"row_index": 0, "cells": "x"}), + table("ББ", row(0, ("1", "a", 100.0))), + table("ББ", row(0, (-1, "a", 100.0))), + table("ББ", row(0, (1, "a", 100.0), (1, "b", 100.0))), + ], +) +def test_broken_structure_raises(broken: object) -> None: + with pytest.raises(TableStructureError): + table_candidate("j.json", 0, broken, header_cells(DATA_OCR), RULES) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd $WT/api && .venv/bin/pytest tests/unit/normalizer/test_adapter.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'src.normalizer.ocr.adapter'` + +- [ ] **Step 3: Implement `adapter.py`** по Interfaces. Docstring модуля — ссылка на спеку `scan-gateway` «Адаптер json ИС + шага 1» и §11.1. Никакой очистки текста — её делает пакет. + +- [ ] **Step 4: Run tests** + +Run: `cd $WT && make check` +Expected: зелёный. + +- [ ] **Step 5: Commit** + +```bash +git -C $WT add api/src/normalizer/ocr/adapter.py api/tests/unit/normalizer/test_adapter.py +git -C $WT commit -m "feat(normalizer): адаптер шага 1 — таблица json ИС → FormCandidate (#<issue>)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> +Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" +``` + +--- + +### Task 3: Обход набора json — `normalize`, предупреждения файла, статус и код ошибки + +**Files:** +- Create: `$WT/api/src/normalizer/files.py`, `$WT/api/src/normalizer/ocr/answer_set.py` +- Modify: `$WT/api/src/normalizer/__init__.py` (перенос `_FileError`, `_file_error`, `_response_error`, `_invalid_file` и + констант сообщений в `files.py`; `NullNormalizer` пока берёт их оттуда, строковые предупреждения — как были) +- Test: `$WT/api/tests/unit/normalizer/test_answer_set.py`; вспомогательные `ocr_table`, `response`, `merged` и константы + `OK`, `BAD`, `HEAD`, `BALANCE`, `INCOME` из теста ниже вынести в `$WT/api/tests/unit/normalizer/ocr_builders.py` (их + импортируют тесты задач 4 и 6) + +**Interfaces:** +- Consumes: задача 2 (`header_cells`, `table_candidate`, `TableStructureError`); из пакета — `normalize_candidate`, + `envelope_status`, `make_warning`, `WarningCode`, `EnvelopeStatus`, `StatementDocument`, `StatementWarning`, + `Rules`; из `src.contract` — `ErrorCode`. +- Produces (`src/normalizer/files.py`): + - константы `NOT_OBJECT_MESSAGE`, `NOT_JSON_MESSAGE`, `NOT_MERGED_MESSAGE`, `NO_FILES_MESSAGE`, `BAD_ENTRY_MESSAGE` — + тексты как сейчас в `src/normalizer/__init__.py` + - `@dataclass(frozen=True) FileError(name: str, code: ErrorCode, source_code: str, message: str, summary: str)` с + методом `to_warning(rules: Rules) -> StatementWarning` = `make_warning(WarningCode.JSON_FAILED, + f"{source_code}: {message}", rules, locator=f"file:{name}")`; элемент без имени — `name = f"files[{index}]"` + - `file_error(index: int, entry: object) -> FileError | None` — правило успеха без изменений (`error.code == ""`, №57) +- Produces (`src/normalizer/ocr/answer_set.py`): + - `NO_TABLES_MESSAGE = "ни одной таблицы не разобрано"` + - `@dataclass(frozen=True) SetError(code: ErrorCode, message: str)` + - `@dataclass(frozen=True) AnswerSetResult(status: EnvelopeStatus, documents: tuple[StatementDocument, ...], + file_warnings: tuple[StatementWarning, ...], error: SetError | None)` — `error is not None ⇔ status is ERROR` + - `normalize_answer_set(raw: object, rules: Rules) -> AnswerSetResult` + +Порядок `normalize_answer_set` (спека `scan-gateway` «Ошибки набора json», `statement-normalization` «Шаг 8 — статус +документа и конверта»): +1. `raw` не `{files: list}` → `ERROR` `INVALID_FORMAT` `NOT_MERGED_MESSAGE`; `files == []` → `ERROR` `INVALID_FORMAT` + `NO_FILES_MESSAGE` (как сейчас). +2. Файлы по порядку склейки. `file_error(...)` не `None` → `to_warning(rules)` в `file_warnings` (P5-8: и когда все + json ошибочны). Успешный: `data_ocr` не объект → `JSON_FAILED` «ответ ИС без data_ocr»; `table_structure` не список + → `JSON_FAILED` «ответ ИС без table_structure»; пустой список → `JSON_FAILED` «в ответе ИС нет таблиц» (P5-7); + `locator` у всех трёх — `file:<имя>`. +3. Каждая таблица `i`: `table_candidate` и `normalize_candidate` в одном `try`; любое `Exception` → + `make_warning(WarningCode.TABLE_PARSE_ERROR, f"BACKEND_PARSING_ERROR: {type(exc).__name__}: {exc}", rules, + locator=f"file:{name};table:{i}")`, текст исключения обрезается до 300 символов (P5-9); иначе документ — в + `documents` (заголовок таблицы — `document.header.form_name`, его ставит пакет из `evidence.title`), `file_warnings` + результата — в `file_warnings`. Порядок `file_warnings` — порядок файлов, + внутри файла — порядок таблиц. +4. `status = envelope_status([d.status for d in documents], file_warnings)`; `ERROR` → нет успешного json — + `SetError(<код первого ошибочного>, <его summary>)`, иначе `SetError(RECOGNITION_FAILED, NO_TABLES_MESSAGE)`. + +- [ ] **Step 1: Write the failing test** + +```python +from datetime import date +from typing import Any + +import pytest +from arm_statements import DocStatus, DocumentType, EnvelopeStatus, PeriodKind, Severity, default_rules + +import src.normalizer.ocr.answer_set as answer_set_module +from src.contract import ErrorCode +from src.normalizer.ocr.answer_set import NO_TABLES_MESSAGE, normalize_answer_set + +RULES = default_rules() +OK = {"code": "", "message": ""} +BAD = {"code": "INVALID_DOCUMENT", "message": "Не удалось определить тип документа."} +HEAD = { + "organization": {"form_name": "ООО «Пример»", "confidence": 100.0}, + "inn": {"form_name": "7700000000", "confidence": 100.0}, + "units": {"form_name": "тыс. руб.", "confidence": 100.0}, + "date": {"form_name": "на 31 декабря 2024 г.", "confidence": 100.0}, +} +BALANCE = [("Наименование показателя", "Код", "На 31 декабря 2024 г."), ("Запасы", "1210", "60"), + ("Дебиторы", "1230", "40"), ("Итого II", "1200", "100"), ("НМА", "1110", "50"), ("Итого I", "1100", "50"), + ("БАЛАНС", "1600", "150"), ("Капитал", "1310", "150"), ("Итого III", "1300", "150"), + ("БАЛАНС", "1700", "150")] +INCOME = [("Наименование показателя", "Код", "За 2024 г."), ("Выручка", "2110", "100"), + ("Себестоимость", "2120", "(60)"), ("Валовая прибыль", "2100", "40"), ("Прибыль от продаж", "2200", "40")] + + +def ocr_table(label: str, rows: list[tuple[str, ...]]) -> dict[str, Any]: + return {"label": {"form_name": label, "confidence": 100.0}, "rows": [ + {"row_index": i, "cells": [{"column_index": c, "value": v, "confidence": 100.0} for c, v in enumerate(r)]} + for i, r in enumerate(rows)]} + + +def response(*tables: dict[str, Any], error: dict[str, str] = OK, head: dict[str, Any] = HEAD) -> dict[str, Any]: + return {"file_uid": "x", "error": error, "data_ocr": {**head, "table_structure": list(tables)}} + + +def merged(*files: tuple[str, Any]) -> dict[str, Any]: + return {"job_id": "j", "files": [{"name": n, "response": r, "parse_error": None} for n, r in files]} + + +def test_one_json_two_forms_copy_header() -> None: + res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE), + ocr_table("Отчет о финансовых результатах", INCOME)))), RULES) + assert [(d.locator, d.document_type, d.header.form_name) for d in res.documents] == [ + ("file:j.json;table:0", DocumentType.BALANCE, "Бухгалтерский баланс"), + ("file:j.json;table:1", DocumentType.INCOME, "Отчет о финансовых результатах")] + assert {d.header.inn.value for d in res.documents} == {"7700000000"} + assert res.documents[0].status is DocStatus.DONE and res.error is None + + +def test_lost_ofr_json_is_file_error() -> None: + res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE))), + ("j_ofr.json", response(error=BAD))), RULES) + assert res.status is EnvelopeStatus.NEEDS_CHECK and len(res.documents) == 1 + assert [(w.code, w.severity, w.locator, w.message) for w in res.file_warnings] == [ + ("JSON_FAILED", Severity.ERROR, "file:j_ofr.json", "INVALID_DOCUMENT: Не удалось определить тип документа.")] + + +def test_table_exception_is_file_warning(monkeypatch: pytest.MonkeyPatch) -> None: + real = answer_set_module.normalize_candidate + + def flaky(candidate: Any, rules: Any = None) -> Any: + if candidate.locator.endswith("table:1"): + raise RuntimeError("сломалось") + return real(candidate, rules) + + monkeypatch.setattr(answer_set_module, "normalize_candidate", flaky) + res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE), + ocr_table("Отчет о финансовых результатах", INCOME)))), RULES) + assert [d.document_type for d in res.documents] == [DocumentType.BALANCE] + [w] = res.file_warnings + assert (w.code, w.severity, w.locator) == ("TABLE_PARSE_ERROR", Severity.ERROR, "file:j.json;table:1") + assert w.message == "BACKEND_PARSING_ERROR: RuntimeError: сломалось" + assert res.status is EnvelopeStatus.NEEDS_CHECK + + +def test_broken_table_structure_is_table_parse_error() -> None: + res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE), + {"label": {}, "rows": "x"}))), RULES) + assert [w.code for w in res.file_warnings] == ["TABLE_PARSE_ERROR"] and len(res.documents) == 1 + + +def test_text_only_table_skipped_and_no_documents() -> None: + res = normalize_answer_set(merged(("j.json", response(ocr_table("", [("Руководитель", "Иванов")])))), RULES) + assert [(w.code, w.severity, w.locator) for w in res.file_warnings] == [ + ("TABLE_SKIPPED", Severity.INFO, "file:j.json;table:0")] + assert res.status is EnvelopeStatus.ERROR + assert (res.error.code, res.error.message) == (ErrorCode.RECOGNITION_FAILED, NO_TABLES_MESSAGE) + + +def test_lost_table_with_numbers() -> None: + res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", BALANCE), + ocr_table("", [("Итого", "", "15 000")])))), RULES) + assert [(w.code, w.severity, w.locator) for w in res.file_warnings] == [ + ("TABLE_LOST", Severity.ERROR, "file:j.json;table:1")] + assert res.status is EnvelopeStatus.NEEDS_CHECK + + +def test_all_json_failed_first_code_and_all_listed() -> None: + res = normalize_answer_set(merged(("j.json", response(error=BAD)), ("j_ofr.json", "не объект")), RULES) + assert res.status is EnvelopeStatus.ERROR and res.documents == () + assert (res.error.code, res.error.message) == (ErrorCode.INVALID_DOCUMENT, BAD["message"]) + assert [(w.code, w.locator) for w in res.file_warnings] == [("JSON_FAILED", "file:j.json"), + ("JSON_FAILED", "file:j_ofr.json")] + + +@pytest.mark.parametrize(("data_ocr", "message"), [(None, "ответ ИС без data_ocr"), + ({**HEAD, "table_structure": None}, "ответ ИС без table_structure"), + ({**HEAD, "table_structure": []}, "в ответе ИС нет таблиц")]) +def test_successful_json_without_tables(data_ocr: object, message: str) -> None: + res = normalize_answer_set(merged(("j.json", {"error": OK, "data_ocr": data_ocr})), RULES) + assert [(w.code, w.severity, w.message) for w in res.file_warnings] == [("JSON_FAILED", Severity.ERROR, message)] + assert res.error is not None and res.error.code is ErrorCode.RECOGNITION_FAILED + + +@pytest.mark.parametrize("raw", [None, {"files": "x"}, {"job_id": "j"}]) +def test_not_merged(raw: object) -> None: + res = normalize_answer_set(raw, RULES) + assert res.status is EnvelopeStatus.ERROR and res.error is not None + assert res.error.code is ErrorCode.INVALID_FORMAT + + +# Сквозной тест дат шлюза: строки OCR из test_parses_real_forms (правило — в пакете, план 1, задача 5) +@pytest.mark.parametrize(("label", "expected"), [ + ("На 30 июня\n2025 г", date(2025, 6, 30)), ("За январь - июнь\n2025 г.", date(2025, 6, 30)), + ("30 сентября 2025 r.", date(2025, 9, 30)), ("3l.12.2О24", date(2024, 12, 31)), + ("9 мес. 2025", date(2025, 9, 30)), ("30.09.2025 0:00:00", date(2025, 9, 30)), +]) +def test_ocr_column_dates_end_to_end(label: str, expected: date) -> None: + rows = [("Наименование показателя", "Код", label), *BALANCE[1:]] + head = {**HEAD, "date": {"form_name": "2|0|2|4|", "confidence": 75.0}} + res = normalize_answer_set(merged(("j.json", response(ocr_table("Бухгалтерский баланс", rows), head=head))), + RULES) + doc = res.documents[0] + assert [(c.period_kind, c.period_date) for c in doc.columns] == [(PeriodKind.ABSOLUTE, expected)] + assert doc.header.report_date.value == date(2024, 12, 31) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd $WT/api && .venv/bin/pytest tests/unit/normalizer/test_answer_set.py -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'src.normalizer.ocr.answer_set'` + +- [ ] **Step 3: Implement `files.py` и `answer_set.py`** по Interfaces и порядку выше; `src/normalizer/__init__.py` + импортирует перенесённое из `files.py` (поведение `NullNormalizer` и строки `warnings` пока прежние — меняются в задаче + 4). Старый `OcrNormalizer` не трогать. + +- [ ] **Step 4: Run tests** + +Run: `cd $WT && make check` +Expected: зелёный (в том числе `tests/unit/test_normalizer.py` и `test_worker.py` без изменений). + +- [ ] **Step 5: Commit** + +```bash +git -C $WT add api/src/normalizer api/tests/unit/normalizer/test_answer_set.py +git -C $WT commit -m "feat(normalizer): набор json ИС через arm_statements — JSON_FAILED, TABLE_PARSE_ERROR, статус конверта (#<issue>)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> +Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" +``` + +--- + +### Task 4: Контракт v2.0 и переключение нормализатора + +Одна задача: модели контракта v1 и старый `ocr/document.py` не компилируются вместе с моделями v2, а `make check` должен +оставаться зелёным. + +**Files:** +- Copy: `docs/contracts/*.schema.json` без `*.v<N>.schema.json` → `$WT/api/contracts/`; `docs/contracts/examples/*.json` + (только верхний уровень) → `$WT/api/contracts/examples/` (источник — `$WS/docs`, docs `main` после #117) +- Modify: `$WT/api/src/contract/{common,request,message,result,__init__}.py` +- Modify: `$WT/api/src/normalizer/__init__.py`, `$WT/api/src/normalizer/ocr/__init__.py`, `$WT/api/src/core/worker.py`, + `$WT/api/src/app.py` +- Delete: `$WT/api/src/normalizer/ocr/document.py`, `$WT/api/tests/unit/normalizer/test_document.py` +- Test: `$WT/api/tests/unit/test_contract.py`, `$WT/api/tests/unit/normalizer/test_contract_boundary.py`, + `$WT/api/tests/unit/normalizer/test_ocr_normalizer.py` (переписать), `$WT/api/tests/unit/test_normalizer.py`, + `$WT/api/tests/unit/test_worker.py`, `$WT/api/tests/worker_helpers.py`, `$WT/api/tests/unit/test_app.py` +- Не трогать: `test_contract.py::test_vendored_contracts_are_identical_to_docs`, `tests/contract_identity.py`, + `tests/unit/test_contract_identity.py` (план 0) + +**Interfaces:** +- Consumes: задача 3 (`normalize_answer_set`, `AnswerSetResult`, `FileError.to_warning`); из пакета — `to_contract`, + `warning_to_contract`, `statements_version`, `default_rules`, `Rules`, `StatementDocument`, `StatementWarning`. +- Produces (`src/contract/common.py`): + - `CONTRACT_VERSION = "2.0"` (файл результата и конверт, включая ack); `SCHEMA_VERSION_PATTERN = r"^2\.\d+$"`; + `REQUEST_SCHEMA_VERSION_PATTERN = r"^1\.\d+$"`, `RequestSchemaVersion`; `SUPPORTED_MAJOR = 1` — MAJOR **запроса** + (`is_supported_major` — по шаблону запроса); `request.py` — `schema_version: RequestSchemaVersion` + - `class Severity(StrEnum)`: `ERROR`, `INFO`; `HeaderFieldName = Literal["organization_name", "inn", "report_date", + "units", "okud", "okpo", "okopf", "okfs", "okei", "address"]` + - `DocumentWarning(ContractModel)`: `code` (шаблон `^[A-Z][A-Z0-9_]*$`), `severity: Severity`, `message: str`, + `line_refs: OptionalNotNull[list[str]]`, `column_index: OptionalNotNull[IntLike]`, `field: + OptionalNotNull[HeaderFieldName]`; `FileWarning(DocumentWarning)` + `locator: OptionalNotNull[str]` + - `Metrics.warnings: OptionalNotNull[list[FileWarning]]`; `ServiceInfo.statements_version: OptionalNotNull[NonEmptyStr]` +- Produces (`src/contract/result.py`) — модели v2.0 буквально по `recognition-result.schema.json` v2.0: `OsvDocument` + + `status`, `warnings` (и прочие поля v2.0 ОСВ — по схеме); `StatementHeader` (все поля схемы, включая `form_name`, + `<поле>_text`, `<поле>_confidence`, `needs_review_fields`); `StatementColumn(column_index, label, label_confidence, + period_date, period_kind)`; `StatementValue(column_index, amount, string_value, text, confidence, is_recognized, + needs_review)`; `StatementLine` (все поля схемы; валидатор: `line_status == "ok"` ⇔ `line_code` не `None`, `detail` ⇒ + `parent_ref` строка); `FailedRelation(column_index: int, relation, codes, expected: Money | None, actual: Money | + None)` (`null` — цель или все члены пусты, план 1 P1-21); + `StatementChecks(relations_failed, soft_relations_failed)`; `StatementDocument(document_type: Literal["BALANCE", + "INCOME", "DISCLOSURE"], form_variant: Literal["full", "simplified"] | None` (обязательно), `locator, header, columns, + lines, status, warnings, checks: OptionalNotNull[StatementChecks])`; `RecognitionResult` — валидатор «есть + `StatementDocument` ⇒ `service.statements_version` задан». +- Перевод в контракт — пакетом (план 1, задача 19): `StatementDocument.model_validate(to_contract(document))` и + `FileWarning.model_validate(warning_to_contract(warning, file_level=True))`; уплощение шапки (в том числе `form_name` + из `document.header.form_name`), отбрасывание `column_key` и пустых необязательных ключей предупреждений, `checks` + нет ⇒ ключа нет — правила `to_contract` (P5-15). Модуля `contract_map.py` нет. +- Produces (`src/normalizer/__init__.py`): + - `NormalizeOutcome`: `warnings: list[FileWarning]`, `documents: list[StatementDocument]` (v2.0), новое поле + `statements_version: str | None = None`; инвариант дополнен: `documents` непусты ⇒ `statements_version` задан + - `NullNormalizer` — `warnings` = `JSON_FAILED` (`file_error(...).to_warning(default_rules())` → + `FileWarning.model_validate(warning_to_contract(..., file_level=True))`); итог прежний (`NOT_IMPLEMENTED` / код + первого) +- Produces (`src/normalizer/ocr/__init__.py`): + - `OcrNormalizer(rules: Rules | None = None, *, version: str | None = None)` — `version is None` → + `statements_version()`; всё ещё `None` → `ValueError("копия arm_statements без VERSION: разложите её + sync-statements")` (P5-11); свойство `statements_version: str` + - `OcrNormalizer.normalize(raw: object) -> NormalizeOutcome` — `normalize_answer_set` → статус (`EnvelopeStatus` → + `ResultStatus` по значению), документы — `StatementDocument.model_validate(to_contract(doc))`, `warnings` — + `FileWarning.model_validate(warning_to_contract(w, file_level=True))`, `metrics = + Metrics(rows=<сумма len(lines)>)` при документах (P5-18), `error` — из `SetError`, `statements_version` +- Produces (`src/core/worker.py`): + - `build_result(outcome: NormalizeOutcome, *, job_id: uuid.UUID, file_id: uuid.UUID, service: ServiceInfo, + raw_s3_key: str, metrics: Metrics | None) -> RecognitionResult` — то, что сейчас собирается внутри `_accept_answer`; + `schema_version = CONTRACT_VERSION`, `service` с `statements_version` исхода (P5-11) + - `_outcome_metrics` — структуры `FileWarning`: `message` через `clean_text(…, MAX_WARNING_CHARS)`; больше + `MAX_WARNINGS` → берутся все `error` по порядку, затем `info` по порядку до `MAX_WARNINGS`, итоговый порядок — + исходный; строки-сводки «ещё предупреждений» нет (P5-10); тот же список — в конверте и в файле результата +- `src/app.py`: `OcrNormalizer(default_rules())` вместо `OcrNormalizer(load_rules(settings.SCAN_RULES_DIR))` + +- [ ] **Step 1: Скопировать контракт и написать падающие тесты.** Копирование: + +```bash +cp $WS/docs/contracts/recognition-request.schema.json $WS/docs/contracts/recognition-result.schema.json \ + $WS/docs/contracts/recognition-result-message.schema.json $WT/api/contracts/ +cp $WS/docs/contracts/examples/*.json $WT/api/contracts/examples/ +``` + +Тесты (`tests/unit/normalizer/test_contract_boundary.py` — новые: граница «пакет → модели шлюза»; правила перевода +проверяет пакет, здесь — что его `dict` проходит модели и схему v2.0 шлюза; в `test_contract.py` — добавить к +существующему набору; +валидатор схемы — `VALIDATORS` из `test_contract.py` или такой же реестр; `merged`, `response`, `ocr_table`, `BALANCE`, +`INCOME`, `BAD` — из `ocr_builders.py`; `SERVICE = ServiceInfo(name="scan-gateway", version="test")`; `ocr_module` — +`import src.normalizer.ocr as ocr_module`): + +```python +def test_mapped_document_passes_schema_v2() -> None: + outcome = OcrNormalizer(default_rules(), version="abc1234").normalize(merged(("j.json", response( + ocr_table("Бухгалтерский баланс", BALANCE), ocr_table("Отчет о финансовых результатах", INCOME))))) + result = build_result(outcome, job_id=uuid.uuid4(), file_id=uuid.uuid4(), service=SERVICE, + raw_s3_key="raw/x.json", metrics=None) + payload = result.to_wire() + VALIDATORS["result"].validate(payload) + assert payload["schema_version"] == "2.0" and payload["service"]["statements_version"] == "abc1234" + head = payload["documents"][0]["header"] + assert (head["form_name"], head["inn"], head["inn_text"], head["inn_confidence"]) == ( + "Бухгалтерский баланс", "7700000000", "7700000000", 100.0) + assert "column_key" not in json.dumps(payload) + + +def test_package_warning_fits_file_warning_model() -> None: + w = make_warning(WarningCode.ROW_SPLIT, "разлеплено", default_rules(), line_refs=("row:3#1", "row:3#2")) + assert FileWarning.model_validate(warning_to_contract(w, file_level=True)).to_wire() == { + "code": "ROW_SPLIT", "severity": "info", "message": "разлеплено", "line_refs": ["row:3#1", "row:3#2"]} + + +def test_empty_target_relation_passes_model() -> None: + rows = [("Наименование показателя", "Код", "На 31 декабря 2024 г."), ("Запасы", "1210", "60"), + ("Итого II", "1200", "60")] + outcome = OcrNormalizer(default_rules(), version="abc1234").normalize( + merged(("j.json", response(ocr_table("Бухгалтерский баланс", rows))))) + failed = outcome.documents[0].checks.relations_failed + assert any(f.relation == "1600 = 1100 + 1200" and f.expected is None for f in failed) + + +def test_null_normalizer_structured_json_failed() -> None: + outcome = NullNormalizer().normalize(merged(("j.json", response()), ("j_ofr.json", response(error=BAD)))) + assert outcome.error is not None and outcome.error.code is ErrorCode.NOT_IMPLEMENTED + assert [w.to_wire() for w in outcome.warnings] == [{"code": "JSON_FAILED", "severity": "error", + "message": "INVALID_DOCUMENT: Не удалось определить тип документа.", "locator": "file:j_ofr.json"}] + + +def test_normalizer_requires_statements_version(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ocr_module, "statements_version", lambda path=None: None) + with pytest.raises(ValueError, match="без VERSION"): + OcrNormalizer(default_rules()) +``` + +В `test_contract.py` — добавить: ack, done и error из `build_*_message` имеют `schema_version == "2.0"` и проходят схему +конверта; `metrics.warnings` из строки (`["a"]`) отвергают и схема, и модель; документ `document_type = "OTHER"`, документ +без `form_variant`, строка `unreadable` с `line_code = "1100"`, файл со `statementDocument` без +`service.statements_version` — отвергают и схема, и модель; запрос `schema_version = "2.0"` по-прежнему +`is_supported_major(...) is False`. В `test_worker.py` — сценарий R-30 переписать под P5-10: 300 предупреждений (100 +`error` и 200 `info` вперемешку) → в конверте ровно 100 и все `error` среди них, `message` не длиннее +`MAX_WARNING_CHARS`, файл результата несёт тот же список. В `worker_helpers.py` и `test_worker.py` — ожидаемые +`schema_version`/`result_schema_version` конвертов `"2.0"`, строки `metrics.warnings` — структуры `JSON_FAILED`. +`test_ocr_normalizer.py` переписать: синтетические сценарии задачи 3 через `OcrNormalizer.normalize` проверяют +`NormalizeOutcome` (статус, коды ошибок, `warnings` — `FileWarning`, `metrics.rows`), реальные json отсюда уходят в +задачу 6. `test_app.py::test_create_app_fails_on_broken_rules` заменить на `test_create_app_uses_package_rules` +(`app.state.normalizer.statements_version` равен содержимому `api/arm_statements/VERSION`). + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd $WT/api && .venv/bin/pytest tests/unit/test_contract.py tests/unit/normalizer/test_contract_boundary.py -v` +Expected: FAIL — примеры v2.0 не проходят модели v1 (`document_type`, `warnings`), `OcrNormalizer` отдаёт документы v1. + +- [ ] **Step 3: Модели контракта v2.0** (`src/contract/*`) по Interfaces; модуль-docstring — «контракт v2.0, запрос — + v1.0». Run: `cd $WT/api && .venv/bin/pytest tests/unit/test_contract.py -v` → PASS. + +- [ ] **Step 4: `NormalizeOutcome`, `NullNormalizer`, `OcrNormalizer` (перевод — `to_contract` пакета), + `worker.build_result`, `_outcome_metrics`, `app.py`** по Interfaces; удалить `ocr/document.py`, старый код `ocr/__init__.py` и + `test_document.py`; убрать из `src/normalizer/__init__.py` цикл импортов («в конце модуля: …») — `OcrNormalizer` + больше не берёт помощники из `src.normalizer` (они в `files.py`). + +- [ ] **Step 5: Run all tests** + +Run: `cd $WT && make check` +Expected: зелёный; `grep -rn "duplicate_line_codes\|OTHER\"" api/src` — пусто. + +- [ ] **Step 6: Commit** + +```bash +git -C $WT add -A api +git -C $WT commit -m "feat: контракт результата 2.0 — документы и предупреждения файла общего кода arm_statements (#<issue>)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> +Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" +``` + +--- + +### Task 5: Удаление старого нормализатора, справочников и `SCAN_RULES_DIR` + +**Files:** +- Delete: `$WT/api/src/normalizer/ocr/{text,dates,amounts,layout,rows,header}.py`, `$WT/api/src/normalizer/rules.py`, + `$WT/api/rules/` (`statement_lines.yaml`, `statement_labels.yaml`, `ocr_lookalikes.yaml`), + `$WT/api/tests/unit/normalizer/test_{text,dates,amounts,layout,rows,header,rules}.py` +- Modify: `$WT/api/src/core/config.py` (поле `SCAN_RULES_DIR`), `$WT/api/.env.example`, `$WT/api/.env.test` (если + есть строка), `$WT/api/src/app.py` (docstring про справочники) + +**Interfaces:** +- Consumes: задача 4 (ничто в `src/` старые модули не импортирует). +- Produces: в `api/src` и `api/tests` нет ссылок на удалённое; `Settings` без `SCAN_RULES_DIR` (`extra="ignore"` — + оставшаяся в окружении контура переменная безвредна, P5-12). + +- [ ] **Step 1: Удалить файлы и поле настроек** (по Files). + +- [ ] **Step 2: Проверка отсутствия ссылок** + +Run: `cd $WT && grep -rn "statement_lines\|statement_labels\|ocr_lookalikes\|SCAN_RULES_DIR\|normalizer\.rules\|normalizer\.ocr\.\(text\|dates\|amounts\|layout\|rows\|header\|document\)" api/src api/tests api/.env.example Dockerfile` +Expected: пусто (README и CLAUDE.md — задача 7). + +- [ ] **Step 3: Run tests** + +Run: `cd $WT && make check` +Expected: зелёный. + +- [ ] **Step 4: Commit** + +```bash +git -C $WT add -A api +git -C $WT commit -m "refactor: удалён собственный нормализатор шагов 2–8, справочник строк и SCAN_RULES_DIR (#<issue>)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> +Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" +``` + +--- + +### Task 6: Golden v2 и Review Focus на реальных ответах ИС + +**Files:** +- Modify: `$WT/api/tests/golden_update.py`, `$WT/api/tests/unit/normalizer/test_golden.py` +- Create: `$WT/api/tests/unit/normalizer/test_real_answers.py` +- Modify (fixtures): `$FX/ocr/expected_normalize/*.json` (пересборка), `$FX/ocr/expected_normalize/README.md` + +**Interfaces:** +- Consumes: задача 4 (`OcrNormalizer`, `build_result`, `_outcome_metrics` — или публичная обёртка, если исполнитель + задачи 4 её выделил; `NormalizeOutcome`). +- Produces (`tests/golden_update.py`): + - `outcome_to_json(outcome: NormalizeOutcome) -> dict[str, Any]` — формат golden v2 (P5-14): успех — из + `build_result(...).to_wire()` с фиктивными `JOB`/`FILE` ключи `schema_version`, `status`, `metrics`, `documents` + (`job_id`, `file_id`, `service`, `raw_s3_key` не пишутся — `statements_version` меняется при каждой раскладке); + ошибка — `{schema_version: "2.0", status: "error", error, metrics, documents: []}` + - `full_payload(outcome) -> dict[str, Any]` — полный файл результата для проверки схемой (только успех) + - `real_set(name)`, `golden_cases()` — без изменений + +- [ ] **Step 1: Write the failing tests.** `test_golden.py`: сравнение с эталоном (как сейчас) **плюс** для `status != + error` — `VALIDATORS["result"].validate(full_payload(outcome))`. `test_real_answers.py` (маркер `fixtures`, пропуск без + fixtures — как в `test_golden.py`; `JOB`, `real_set` — из `tests.golden_update`; модели — из `src.contract.result`): + +```python +NORMALIZER = OcrNormalizer(default_rules(), version="0" * 40) + + +def run(name: str) -> NormalizeOutcome: + return NORMALIZER.normalize(real_set(name)) + + +def lines(doc: StatementDocument) -> dict[str, StatementLine]: + refs = [ln.ref for ln in doc.lines] + assert len(refs) == len(set(refs)) and [ln.row_index for ln in doc.lines] == list(range(len(doc.lines))) + return {ln.ref: ln for ln in doc.lines} + + +def value(line: StatementLine, column_index: int) -> StatementValue: + return next(v for v in line.values if v.column_index == column_index) + + +def test_aep_3_junk_codes_and_dirty_amounts() -> None: # Review Focus 1, 4 + out = run("aep_3") + assert out.status is ResultStatus.NEEDS_CHECK + bal, inc = out.documents + assert [(d.document_type, d.locator, d.status) for d in out.documents] == [ + ("BALANCE", f"file:{JOB}.json;table:0", "needs_check"), ("INCOME", f"file:{JOB}.json;table:1", "needs_check")] + assert [c.column_index for c in bal.columns] == [2, 4, 5] + b = lines(bal) + assert (b["row:0"].line_status, b["row:0"].line_code_text) == ("missing", "НИ") + assert (b["row:2"].line_status, b["row:2"].parent_ref) == ("detail", "row:1") + assert (b["row:3"].line_status, b["row:3"].line_code) == ("ok", "1140") + assert {b["row:12"].line_status, b["row:31"].line_status} == {"unreadable"} + assert (b["row:17"].line_status, b["row:17"].parent_ref) == ("detail", "row:16") + i = lines(inc) + revenue = value(i["row:0"], 2) + assert i["row:0"].line_status == "missing" + assert (revenue.amount, revenue.string_value, revenue.is_recognized) == (None, "211(1 |", False) + cost = value(i["row:1"], 2) + assert (i["row:1"].line_code, cost.amount, cost.is_recognized) == ("2120", Decimal("-7744536"), False) + assert value(i["row:8"], 2).amount is None + [failed] = [w for w in out.warnings if w.code == "JSON_FAILED"] + assert (failed.severity, failed.locator) == ("error", f"file:{JOB}_ofr.json") + assert failed.message == "INVALID_DOCUMENT: Не удалось определить тип документа." + + +def test_aep_6_garbage_code_and_repeats() -> None: # Review Focus 2 + bal, inc = run("aep_6").documents + b = lines(bal) + assert (b["row:6"].line_status, b["row:6"].line_code, b["row:6"].line_code_text) == ("unreadable", None, + "44 30\nI |") + assert "row:6#1" not in b and all(v.amount is None for v in b["row:6"].values) + assert [ln.line_code for ln in bal.lines].count("1220") == 2 and [ln.line_code for ln in bal.lines].count("1520") == 2 + i = lines(inc) + assert [ln.line_code for ln in inc.lines].count("2410") == 2 and i["row:4"].line_status == "detail" + assert bal.status == "needs_check" + + +@pytest.mark.parametrize(("name", "row", "amounts"), [ + ("БО 1 кв 2025", 73, (Decimal("43274"), Decimal("198622"))), + ("Бухгалтерская отчетность 1 кв.25", 93, (Decimal("917775"), Decimal("2371333"))), +]) +def test_q1_2025_split_repeats_and_done(name: str, row: int, amounts: tuple[Decimal, Decimal]) -> None: # RF 3 + out = run(name) + bal, _ = out.documents + b = lines(bal) + first, second = b[f"row:{row}#1"], b[f"row:{row}#2"] + assert (first.line_code, second.line_code) == ("1500", "1700") + assert (value(first, 2).amount, value(second, 2).amount) == amounts + assert any(w.code == "ROW_SPLIT" and w.severity == "info" for w in bal.warnings) + codes = [ln.line_code for ln in bal.lines] + assert codes.count("1220") == 2 and codes.count("1520") == 2 + # Р-23: расшифровки не держат документ; расхождение — находка для пакета (P5-17), не правка шлюза + assert [d.status for d in out.documents] == ["done", "done"], _holders(out) + assert out.status is ResultStatus.DONE + + +@pytest.mark.parametrize("name", ["aep_5", "aep_8_form4"]) +def test_all_json_failed_is_error(name: str) -> None: # Review Focus 4, 5 + out = run(name) + assert (out.status, out.documents) == (ResultStatus.ERROR, []) + assert out.error is not None and out.error.code is ErrorCode.INVALID_DOCUMENT + assert out.error.message == "Не удалось определить тип документа." + assert [(w.code, w.locator) for w in out.warnings] == [("JSON_FAILED", f"file:{JOB}.json")] +``` + +`_holders(out)` — вспомогательная функция теста: список `(locator, ref, column_index, text, confidence)` значений и строк с +`needs_review`, строк не `ok`/`detail` и предупреждений `error` — печатается при падении утверждения `done`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd $WT/api && .venv/bin/pytest tests/unit/normalizer/test_real_answers.py tests/unit/normalizer/test_golden.py -v` +Expected: `test_real_answers` — PASS или FAIL по существу (правила пакета); `test_golden` — FAIL «эталон разошёлся» (формат v1). + +- [ ] **Step 3: Golden v2.** Обновить `golden_update.py` по Interfaces; `make golden-update` (пишет в `$FX`, fixtures — + worktree ветки golden). `git -C $FX diff --stat` и выборочно `git -C $FX diff` — объяснить в отчёте задачи каждое + крупное изменение (строки без кода больше не пропадают, повторы кода, `needs_review`, статусы документов, + предупреждения-структуры). Обновить `$FX/ocr/expected_normalize/README.md`: формат `{schema_version, status, error, + metrics, documents}` контракта v2.0, `metrics.warnings` — структуры, ключи `job_id`/`file_id`/`service` не пишутся. + +- [ ] **Step 4: Разбор Review Focus.** Если утверждение `test_real_answers.py` падает: проверить кандидата адаптера + (`table_candidate` по той же таблице — `ref`, тексты, уверенности как в json). Кандидат верен → находка `O-n` для + пакета с выводом `_holders` и строкой json; утверждение **не** ослаблять без решения контроллера (`R-n`), копию пакета + не править. Кандидат неверен → исправить адаптер (задача 2) с тестом. + +- [ ] **Step 5: Run tests** + +Run: `cd $WT && make check` +Expected: зелёный (или задокументированная находка `O-n` с решением контроллера по шагу 4). + +- [ ] **Step 6: Доля `done` для PR** (proposal: «фактическая доля `done` фиксируется в PR scan-gateway»): + +```bash +python3 -I -c " +import json, pathlib, collections +for p in sorted(pathlib.Path('$FX/ocr/expected_normalize').glob('*.json')): + g = json.loads(p.read_text(encoding='utf-8')) + print(p.stem, g['status'], collections.Counter(d['status'] for d in g['documents'])) +" +``` + +Вывод — в отчёт задачи (его переносит задача 7 в описание PR). + +- [ ] **Step 7: Commit (две ветки)** + +```bash +git -C $FX add ocr/expected_normalize +git -C $FX commit -m "golden: эталоны scan-gateway по контракту 2.0 и общему коду arm_statements (scan-gateway#<issue>)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> +Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" +git -C $WT add api/tests +git -C $WT commit -m "test: golden v2 и Review Focus на реальных ответах ИС (#<issue>)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> +Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" +``` + +--- + +### Task 7: Интеграционные тесты, документация, `check-full`, PR + +**Files:** +- Modify: `$WT/api/tests/integration/test_roundtrip.py`, `$WT/README.md`, `$WT/CLAUDE.md`, + `$WT/openspec/changes/statements-v2/tasks.md`, `$WT/openspec/changes/statements-v2/design.md` + +**Interfaces:** +- Consumes: задачи 1–6. +- Produces: PR fixtures и PR scan-gateway. + +- [ ] **Step 1: `test_roundtrip.py`.** Ожидания конвертов — `schema_version == "2.0"` (ack тоже); файл результата + проверяется схемой v2.0; aep_3: документы `BALANCE`, `INCOME`, `JSON_FAILED` про `<job_id>_ofr.json` структурой, + документы и `metrics.warnings` равны golden (с заменой `JOB` на `job_id` задания в `locator`, как сейчас для строк); + aep_6 — равенство golden; `ok_doc.pdf` — `line_name` из OCR, без предупреждения «редакция не заполнена»; все файлы + набора ошибочны — `metrics.warnings == [{"code": "JSON_FAILED", "severity": "error", "message": + "INVALID_DOCUMENT: …", "locator": f"file:{job_id}.json"}]`. + +- [ ] **Step 2: README и CLAUDE.md.** README: «Нормализация» — адаптер шага 1 и копия `arm_statements` (что остаётся в + шлюзе, что ушло; ссылка на §11.1, §11.9); раздел «Справочники `api/rules/`» удалить (словари — в пакете, правка — + через `arm-statements`); «Статус результата» — `envelope_status`; «Предупреждения» — таблица предупреждений файла + (`JSON_FAILED`, `TABLE_PARSE_ERROR`, `TABLE_LOST`, `TABLE_SKIPPED`) с уровнем и `locator`, предупреждения документа — + ссылка на §11.9; «Эталоны» — формат v2; «Переменные окружения» — без `SCAN_RULES_DIR`; «Проверки» — `statements-check`. + CLAUDE.md: абзац «Что это» — `statementDocument` v2.0 общим кодом; «Правила» — пункты про перенос `ocr/*`, `api/rules`, + статус и строки предупреждений заменить на: адаптер (`normalizer/ocr/adapter.py`), обход набора + (`normalizer/ocr/answer_set.py`), граница контракта (`to_contract` пакета в `normalizer/ocr/__init__.py`), копия + `api/arm_statements/` не правится (`sync-statements`), предупреждения — структуры; «Добавлено» — новые модули, удалённые — убрать. + +- [ ] **Step 3: Полная проверка** + +Run: `cd /Users/andrey.epifancev/projects/awp-ca/.wt/normalization-sg/docs/dev && docker compose -f docker-compose.yml --project-name arm-ka ps` (если не поднято — `cd $WT && make up`), затем `cd $WT && make check-full; echo "код $?"` +Expected: код 0; `statements-check` — `0` или `10` (предупреждение — в описание PR). + +- [ ] **Step 4: OpenSpec.** Отметить задачи в `tasks.md`; дописать в `design.md` решения `R-n`, принятые по ходу. + Run: `cd $WT && openspec validate statements-v2 --strict` → valid. + +- [ ] **Step 5: Commit и push** + +```bash +git -C $WT add -A +git -C $WT commit -m "docs: README и CLAUDE.md — адаптер OCR, arm_statements, контракт 2.0; интеграционные тесты (#<issue>)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> +Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W" +git -C $FX push -u origin feat/<issue>-golden-statements-v2 +git -C $WT push -u origin feat/<issue>-statements-v2 +``` + +- [ ] **Step 6: PR.** Сначала fixtures (`tea pulls create --login home --repo RSHB/arm-ka-fixtures`): что пересобрано, + почему (ссылка на PR scan-gateway), «сливать первым, PR scan-gateway — сразу за ним». Затем scan-gateway (`tea pulls + create --login home --repo RSHB/arm-scan-gateway`, `Closes #<issue>`, ссылки на arm-ka-docs#116 и PR fixtures): + итог `make check-full` и `statements-check`, доля `done` по golden (задача 6, шаг 6), таблица находок + (`| Маркер | Суть | Статус |`), «Rulings I made» (P5-n и R-n), блок **«Слияние — после выкладки ядра, принимающего + 2.0 (план 3, части А и Б); fixtures PR — первым»**. Слияние — только по команде человека. + +--- + +## Решения автора плана без обсуждения (P5-n) + +Исполнитель задачи 0 переносит их в `design.md` change'а `statements-v2`, контроллер — в `design.md` docs-change'а +(`## Decisions made without discussion`, 18.10.2026). + +- **P5-1.** Сетка OCR — одна строка сетки на строку ИС по возрастанию `row_index`, пропуски `row_index` пустыми строками + не заполняются; ширина строк таблицы — `max(column_index) + 1`. «Плотная по `row_index`» прочитано как «по порядку + `row_index`», `ref` всё равно несёт номер ИС. Цена: шаг 2 не видит «дыры» между строками (в реальных json их нет). +- **P5-2.** Нарушенная структура таблицы ИС (повтор `row_index`, повтор `column_index` в строке, нецелые индексы, `rows` + не список) — исключение адаптера → `TABLE_PARSE_ERROR` всей таблицы. Сегодня такие ячейки молча пропускались или + перезаписывались; повтор `row_index` сломал бы уникальность `ref`. Цена: одна битая ячейка теряет таблицу (видно — + `error`). +- **P5-3.** `value` ячейки: `None` → `""`, не строка → `str(value)`, без `strip`; `confidence`: число (не `bool`) → + `[0, 100]`, иначе `0` (как `clamp_confidence`, §11.1 «NaN → 0»). Цена: числовой `value` ИС (не встречался) даст текст + `"1234.0"`. +- **P5-4.** Ячейка ИС с пустым текстом — `Cell("", …)`, а не `None`: ИС отдаёт ячейку на каждую позицию строки, `None` — + только там, где ячейки нет. Цена: нет. +- **P5-5.** Шапка: поле ИС отсутствует, не объект или `form_name` пустой → `None` (а не `Cell("", 0)`); `ref` полей шапки — + `data_ocr.<поле ИС>` (пакет `ref` шапки в выход не переносит — у `HeaderField` его нет, план 1, раздел API); + `data_ocr.label` (заголовок комплекта) не используется. Цена: `<поле>_text` пустого поля — `null`, а не `""`. +- **P5-6.** `evidence.title = Cell(label.form_name, …, ref="table:<i>.label")`; `header.form_name` контракта — + `document.header.form_name` пакета (= `evidence.title.text` как в источнике, пустой — `null`, план 1 P1-29); шлюз + `form_name` сам не ставит и пару «кандидат → документ» не хранит. +- **P5-7.** Успешный json без `data_ocr`, без `table_structure` или с пустым `table_structure` — `JSON_FAILED` (`error`): + «ничего не выбрасывать молча» (§11, принцип 2). Цена: если ИС вернёт успешный пустой json рядом с нормальными, конверт + будет `needs_check` (вопрос владельцу — Q-1). +- **P5-8.** Все json набора ошибочны — конверт `error` с кодом первого, а `metrics.warnings` всё равно перечисляют + `JSON_FAILED` каждого json (как строки Среза 0): иначе коды остальных json теряются. Цена: спека называет + `JSON_FAILED` «при успешном соседе»; на `error`-конверт ядро `RECOGNIZED` не считает, вреда нет (Q-2). +- **P5-9.** `TABLE_PARSE_ERROR.message` = `BACKEND_PARSING_ERROR: <тип>: <текст>` (текст до 300 символов) — код старого + §11.1 п. 9 сохранён в тексте для поиска по логам и golden. Цена: нет. +- **P5-10.** Предел `metrics.warnings` (100 структур, `message` до 1000 символов) сохранён; при переполнении остаются все + `error`, затем `info`; строки-сводки нет — новый код предупреждения вводится только словарём пакета. Список в конверте + и в файле результата одинаков (спека). Цена: при > 100 предупреждениях часть `info` не видна ни там, ни там (сырой + ответ — в `raw/`). +- **P5-11.** `statements_version` читается при создании `OcrNormalizer`; нет `VERSION` — сервис не стартует. Версия едет в + `NormalizeOutcome.statements_version` и в `service.statements_version` файла результата; в конверт не пишется (в + схеме конверта поля нет). Цена: dev-checkout без `VERSION` не стартует (копию всегда раскладывает скрипт). +- **P5-12.** `api/rules/` удалён целиком, `SCAN_RULES_DIR` убран из настроек; словари — `default_rules()` пакета. Цена: + переопределить словарь без релиза нельзя (и не нужно — словари меняются через `arm-statements`). +- **P5-13.** `CONTRACT_VERSION = "2.0"` — для файла результата и всех конвертов, включая ack; MAJOR запроса — + отдельная константа (`SUPPORTED_MAJOR = 1`). +- **P5-14.** Golden v2 — `{schema_version, status, error, metrics, documents}` файла результата без `job_id`, `file_id`, + `service`, `raw_s3_key` (`statements_version` меняется при каждой раскладке копии); успешный golden дополнительно + проходит схему v2.0. Цена: golden не ловит регресс поля `service`. +- **P5-15.** Перевод в контракт — `to_contract` / `warning_to_contract` пакета (план 1, P1-26): у предупреждений + документа нет `locator` и пустых необязательных ключей (схема не допускает `null` у `column_index` и `field`), у + нарушений §7 нет `column_key`, у предупреждений файла `locator` есть; шлюз только валидирует `dict` своими моделями. + Цена: правка правил перевода — в `arm-statements`, а не здесь. +- **P5-16.** Модули: `OcrNormalizer` остаётся в `src/normalizer/ocr/__init__.py` (меньше правок `app.py` и тестов), + адаптер — `ocr/adapter.py`, обход набора — `ocr/answer_set.py`, ошибки json — `normalizer/files.py` (снимает цикл + импортов `src.normalizer` ↔ `src.normalizer.ocr`); своего конвертера в контракт (`contract_map.py`) нет. +- **P5-17.** Review Focus 3 утверждает `done` для обоих документов «БО 1 кв 2025» и «Бухгалтерская отчетность 1 кв.25» + (proposal, Р-23). Падение при верном кандидате — находка для пакета, а не правка шлюза; ослабить утверждение может + только контроллер рулингом. +- **P5-18.** `metrics.rows` — число строк `lines` всех документов (как в v1.2), теперь включая строки без кода. +- **P5-19.** `NullNormalizer` сохраняется для тестов воркера и выдаёт `JSON_FAILED` через `make_warning` пакета. +- **P5-20.** Обход набора зовёт `normalize_candidate` на каждую таблицу, а не `normalize`: исключение одной таблицы — + `TABLE_PARSE_ERROR` с `locator` таблицы, остальные таблицы разбираются (план 1: `normalize` исключения кандидата не + перехватывает). `AnswerSetResult.documents` — документы пакета как есть (заголовок — в `header.form_name`). + +## Чего не хватает в API пакета для адаптера OCR (O-n) + +Пункты O-2…O-7 закрыты сведением с планом 1 (18.10.2026, его P1-21…P1-31); маркеры сохранены. + +- **O-1.** План 2 (задача 9) подключает копию в scan-gateway отдельным bounded PR и проверяет `default_rules()` в + образе; этот план от него зависит и не дублирует. Симлинк `arm-statements` в зеркальном workspace `$WS` нужен + `statements-check` (`../arm-statements`) — в плане 2 для зеркала хостов он не упомянут, здесь добавлен (задача 0). +- **O-2.** Закрыт: `StatementHeader.form_name` в пакете (P1-29), конвейер ставит его из `evidence.title`; P5-6. +- **O-3.** Закрыт: `FailedRelation.expected` / `actual` — `Decimal | None`, при пустой цели `expected = None` (P1-21); + модель шлюза — `Money | None`, тест `test_empty_target_relation_passes_model` (задача 4). +- **O-4.** Закрыт: `to_contract` / `warning_to_contract` в пакете (P1-26); `contract_map.py` не пишется. +- **O-5.** Закрыт: `WarningCode` (`StrEnum`) в API, `make_warning` с неизвестным кодом — `KeyError` (P1-30); шлюз + передаёт `WarningCode.JSON_FAILED`, `WarningCode.TABLE_PARSE_ERROR`. +- **O-6.** Закрыт: `ref` ячеек шапки и `evidence` пакет в выход не переносит (раздел API плана 1, «Вход шага 1»). +- **O-7.** Закрыт: в API плана 1 записано, что `normalize` исключения кандидата не перехватывает; шлюз зовёт + `normalize_candidate` по таблице (P5-20). + +## Открытые вопросы владельцу (Q-n) + +- **Q-1.** Успешный json ИС без таблиц (`error.code == ""`, `table_structure == []`) — `JSON_FAILED` (`error`) или + молча пропустить? Образца нет; план выбрал `JSON_FAILED` (P5-7). +- **Q-2.** Все json набора ошибочны — перечислять `JSON_FAILED` каждого json в `metrics.warnings` конверта `error` + (P5-8) или только код первого, как буквально в спеке? +- **Q-3.** Если Review Focus 3 покажет `needs_check` у «БО 1 кв 2025» / «Бухгалтерская отчетность 1 кв.25» по причине, + которую правила пакета считают законной, — принимаем как факт (фиксируется в PR) или это дефект правил пакета? diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-6-arm-web.md b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-6-arm-web.md new file mode 100644 index 0000000..dea9726 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-6-arm-web.md @@ -0,0 +1,933 @@ +# План 6 — arm-web: экран формы ББ/ОФР и карточка файла по API v5.19. План реализации + +> **Для исполнителей-агентов:** обязательный навык — superpowers:subagent-driven-development (в плане 12 задач, задачи +> опираются на интерфейсы друг друга). Контроллер SDD — фоновый сабагент на модели **sonnet**; исполнители — по SDD Model +> Selection (`~/.claude/CLAUDE.md`, «Исполнение»): T1 — haiku (типы и фикстура заданы планом), остальные — sonnet, +> финальное ревью ветки — opus. Шаги размечены чекбоксами (`- [ ]`). + +**Goal:** экран проверки формы ББ/ОФР [41] показывает все строки источника на своих местах (строки без кода — под +ближайшей предыдущей строкой с кодом, расшифровки — под переключателем), подсвечивает только то, что требует проверки +(`needs_review`), показывает предупреждения и нарушения у места, даёт в режиме «Редактировать» разобрать строки, +подтвердить ячейки, коды и даты одним `PATCH`, блокирует «Подтвердить данные» при неразобранных строках; карточка файла +читает `job.warnings` структурами. + +**Architecture:** вся логика — чистые функции в `.ts` рядом с компонентами (`src/widgets/DocumentReview/`, +`src/widgets/FilesDrawer/`), покрытые vitest; компоненты React только рисуют то, что вернули функции, и не тестируются +юнит-тестами (соглашение `arm-web/CLAUDE.md`). Черновик правок аналитика — одна структура `StatementDraft`, из которой +одна функция собирает тело единственного `PATCH`. Экран проверяется сценарием в Playwright на локальном стенде; до того +как сервисы распознавания начнут слать контракт 2.0, строки со статусами подставляются в ответ `GET …/statement` +перехватом запроса (фикстура плана). + +**Tech Stack:** React 18 + TypeScript strict, antd 6 (`Table`, `AutoComplete`, `Dropdown`, `Popover`, `Tooltip`, +`Switch`), rsbuild, vitest **3.1.4**, biome + ESLint + Prettier; Playwright (MCP-плагин сессии) для сценария; `tea` +(Gitea), OpenSpec. + +**Spec:** `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/` +(ветка `feat/116-unified-normalization` репозитория arm-ka-docs): главное — `specs/arm-web/spec.md`; API, которое экран +потребляет, — `specs/arm-core/spec.md` (требования «Ответ `GET …/statement` по v2.0», «PATCH формы — строки, колонки, +подтверждение, наименование организации», «Подтверждение формы — порядок 409», «Предупреждения и нарушения документа — +`document_issue`», «Предупреждения файла и ОСВ в API»); гайд фронта — `dev/frontend-api-guide.md` §5.6.1; правила — +`context/05-domain-rules.md` §9.5 п. 2, §11.9 (таблица предупреждений: коды и уровни); точные схемы ответов и коды — +план 3 (`plans/2026-10-18-plan-3-arm-core.md`), задачи A5, A8, A11, A12, A13; решения — `design.md` (Р-14 экран, Р-23 +значения `detail`, Р-25 подтверждение без правки, S-62 родитель `detail`, S-47 пробелы PATCH). Макет — `mockup/screens/41-document-check.png`, +`41-02-document-check-edit.png`, `39-org-documents.png`. + +--- + +## Место в работе и порядок + +| План | Что | Связь с этим планом | +|---|---|---| +| 3, часть А | arm-core: схема v5.19, `StatementOut` с новыми полями, `PATCH` (`lines`, `columns`, `confirmed`, `organization_name`), порядок 409, `JobBrief.warnings` структурами | задачи T1–T10 пишутся по спеке и фикстуре **до** слияния А; T11 (стенд) — после слияния А в `main` arm-core | +| 3, часть Б | приём контракта 2.0, §7 в `document_issue` | для T11 желательна (нарушения §7 в `issues` у форм 1.x появляются с Б) | +| сервисы (шаг 5 `proposal.md`) | tabular-parser и scan-gateway шлют 2.0 | до них реальных строк `unreadable` / `missing` / `detail` на стенде нет — их показывает перехват (P6-14); повторный прогон на `aep_3` после шага 5 — follow-up | +| `sync-web` | перенос `arm-web@test` в `arm-core/web` | после merge PR этого плана в `test`; снимок контура — только вместе с ядром А + Б (O-9 плана 3) | + +**Выкладка:** фронт выходит в той же выкладке, что ядро: `FileOut.job.warnings` меняет тип со строк на объекты +(несовместимо), а встроенный фронт контура — `arm-core/web`, который обновляется только `make -C docs sync-web`. + +## Как это устроено — для владельца + +- **Раскладка «как на бумаге».** Ядро отдаёт строки списком; разделы баланса и места итогов строит фронт + (`formLayout.ts`, так уже работает с Среза 3b). Новое: строки без кода (`unreadable`, `missing`) и расшифровки + (`detail`) не участвуют в раскладке по коду, а «прицепляются» к строке с кодом — к той, что стояла перед ними в файле + (`row_index`), расшифровка — к своему родителю (`parent_row_index`). Это отдельная чистая функция, её проверяют тесты + на сценариях спеки («ИЧ,250 под 1240»). +- **Почему черновик, а не запрос на каждое действие.** Спека требует: «Сохранить» отправляет **один** `PATCH`. Поэтому + всё, что аналитик делает в режиме «Редактировать» (суммы, подтверждения ячеек, коды строк, даты колонок, шапка), + копится в одном объекте `StatementDraft`, а функция `buildStatementPatch` превращает его в тело запроса и заранее + ловит ошибки, которые иначе вернул бы сервер (неверный формат кода или даты, конфликт «шаблонная строка + тот же код»). + Ответ сервера целиком заменяет состояние экрана — так пересчитанные ядром статусы и проверки всегда актуальны. +- **Почему тесты только на функции.** Так принято в arm-web: компоненты не тестируются, а всё, что решает «что + показать», вынесено в `.ts`-функции. Компоненты проверяются руками в браузере по сценарию T11 со сверкой с макетом. +- **Новых npm-пакетов нет:** всё нужное (выпадающая подсказка кода, меню строки, всплывающий список) есть в antd; в + зеркале npm банка не всё доступно (vitest ≤ 3.1.4). + +## Разбивка на PR + +| | | +|---|---| +| Issue | RSHB/arm-web: «Единая нормализация ББ/ОФР: экран формы и карточка файла по API v5.19 (arm-ka-docs#116)» | +| Ветка | `feat/<issue>-statements-v2` (база — `origin/test`) | +| Зеркальный workspace | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-web/` | +| Worktree arm-web | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-web/arm-web` | +| Change OpenSpec arm-web | `openspec/changes/statements-v2/` | +| PR | в `test` (не в `main`); merge — только по команде владельца | +| После merge | `make -C /Users/andrey.epifancev/projects/awp-ca/docs sync-web` → PR в arm-core (`arm-core/web` напрямую не править) | + +## Global Constraints + +- Типы `src/types/api.ts` повторяют схемы ядра v5.19 (спека `arm-core` «Ответ `GET …/statement` по v2.0», план 3 A5/A8) + и гайд §5.6.1; полей, которых там нет, не придумывать. +- Все запросы — через `src/api/client.ts` (`api.get|post|patch`); прямого `fetch` нет. +- Суммы — строки (`string | null`); арифметики над суммами на фронте нет; `Number()` — только для уверенности (0..100). +- Подсветка ячейки «требует внимания» — **только** по `values[].needs_review`; по `confidence < 100` и + `is_recognized = false` напрямую не подсвечивать; «исправлено» (`is_modified`) — прежний синий. +- Тексты интерфейса — дословно: «Показать расшифровки (N)», «Разберите строки без кода: N», «Это расшифровка», + «Назначить код», «Сделать расшифровкой», «Подтвердить код», «Подтвердить данные», «Редактировать», «Сохранить». +- Переключателя «как в файле» нет (Р-14). +- «Сохранить» отправляет ровно один `PATCH /documents/{id}/statement` (`header`, `values`, `columns`, `lines`); пустой + черновик — запроса нет; ответ заменяет `StatementOut` целиком. +- Коды ошибок — дословно: 409 `LINES_UNRESOLVED` (`detail.lines[] {line_id, row_index, line_code_text}`), `DUPLICATE_FORM` + (`detail.document_id`), `LINE_CODE_TAKEN` (`detail.line_id`, `detail.line_code`), `PERIOD_REQUIRED`, + `CHECKS_NOT_PASSED` (`detail.failures`); 422 `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`, + `LINE_CODE_REQUIRED` (у всех четырёх — `detail.line_id`, `detail.line_code` первого неверного элемента, план 3 + P3-24), `UNKNOWN_COLUMN`, `UNKNOWN_CELL`. +- Новых зависимостей npm нет; `vitest` остаётся `3.1.4`; `package-lock.json` в git не попадает. +- Компоненты (`.tsx`) юнит-тестами не покрываются; каждая чистая функция плана — в `.ts` с `.test.ts` рядом. +- Перед коммитом — `npm run format`; зелёным считается `make lint typecheck test build` (P6-18); перед PR — то же, вывод + в описание PR. +- Язык комментариев, коммитов, issue, PR — русский; идентификаторы — английские, как в API. +- Коммиты заканчиваются строками `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>` и `Claude-Session: + https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W`; описание PR — строкой `🤖 Generated with [Claude + Code](https://claude.com/claude-code)`. +- Находки и решения — с маркерами (`T<n>.<k>`, `C-n`/`I-n`/`M-n`, `R-n`, `O-n`, `H-n`); решения этого плана — `P6-n` + (ниже); исполнитель переносит их в `openspec/changes/statements-v2/design.md` arm-web, раздел «Decisions made without + discussion». +- Каждый экран после правок сверяется с PNG макета (`docs/mockup/screens/41-*.png`, `39-*.png`) при окне 1600×1000 и + прогоняется самим исполнителем в браузере (память владельца «сверять фронт с макетом»). + +## Review Focus + +1. **Якорь строки без кода, который скрыт или повторён** — строка `unreadable` после пустой пятизначной подстроки + (скрыта по умолчанию), после второй из двух строк 2410, после строки в «Прочих строках»: вложение стоит под своим + экземпляром (по ключу строки, не по коду) и не исчезает, когда якорь скрыт. Тесты — T2. +2. **Предупреждение у скрытой или несуществующей строки** — запись `issues` у строки `detail` при выключенном + переключателе (ссылка «Показать» включает расшифровки и ведёт к строке); `line_ids` со строкой, которой нет в ответе + (удалённая шаблонная), и поле шапки, которого нет в блоке шапки (`okpo`, `okei`), — уходят в список документа, а не + теряются. Тесты — T4. +3. **Смешанный черновик в одном сохранении** — сумма и подтверждение одной ячейки, действие строки и сумма той же + строки, сумма в шаблонную строку 1250 и назначение 1250 строке без кода, пустое поле кода, код, равный текущему: + тело `PATCH` не нарушает правил ядра (одно действие на строку, `UNKNOWN_CELL` по P3-10), пустое не отправляется. + Тесты — T6. +4. **Форма, принятая по 1.x** (все строки `ok`, `row_index` по порядку, `source_ref = null`, у чистых значений + `needs_review = false`, в `issues` — `MAPPING_WARNING`) — экран выглядит как до изменения, кроме подсветки по + `needs_review`; переключателя расшифровок нет (N = 0); кнопка «Подтвердить данные» не заблокирована. Тесты — T2, + T9; сценарий — T11. +5. **`job.warnings` старого формата** — строки, смесь строк и объектов, не список, объект без `severity`: панель файлов + не падает, строки показываются как `LEGACY_WARNING` `info`, мусор пропускается. Тесты — T10. + +--- + +## Карта файлов (arm-web, относительно корня репозитория) + +| Файл | Ответственность | +|---|---| +| `src/types/api.ts` | DTO v5.19: строки, значения, колонки, шапка, `issues`, `JobBrief.warnings`, тела `PATCH` | +| `src/utils/labels.ts` | подписи статуса строки и уровня предупреждения | +| `src/utils/dates.ts` | проверка даты `ГГГГ-ММ-ДД` | +| `src/widgets/DocumentReview/__fixtures__/statement-v2.json` | эталонный `StatementOut` v2 для тестов и перехвата в Playwright | +| `src/widgets/DocumentReview/statementGrid.ts` | `StatementOut` → строки сетки `GridRow` с полями v2 | +| `src/widgets/DocumentReview/cellView.ts` | вид ячейки значения и ячейки кода: фон, подсказка, приглушённый текст | +| `src/widgets/DocumentReview/formLayout.ts` | раскладка по разделам + вложения (`unreadable` / `missing` / `detail`), фильтр показа, имена | +| `src/widgets/DocumentReview/issuePlaces.ts` | привязка `issues` к месту (строка, ячейка, колонка, поле, документ), пункты списка | +| `src/widgets/DocumentReview/IssuesPanel.tsx` | список предупреждений документа со ссылкой «Показать» | +| `src/widgets/DocumentReview/headerFields.ts`, `StatementHeaderBlock.tsx` | шапка: наименование организации, `needs_review_fields`, подсказки из `header.source` | +| `src/widgets/DocumentReview/statementDraft.ts` | черновик правок и сборка тела `PATCH` (заменяет `draftToPatch`) | +| `src/widgets/DocumentReview/codeSuggestions.ts`, `lineMenu.ts` | подсказка кода из `edition_lines`, пункты меню строки, ошибка элемента `lines[]` у поля строки | +| `src/widgets/DocumentReview/editRules.ts` | когда у колонки поле даты и у ячейки кнопка подтверждения | +| `src/widgets/DocumentReview/CodeCell.tsx`, `LineMenu.tsx`, `ValueCell.tsx`, `ColumnHeader.tsx` | ячейки таблицы и заголовок колонки | +| `src/widgets/DocumentReview/StatementTable.tsx`, `StatementReview.tsx`, `ChecksPanel.tsx` | сборка экрана, переключатели, сохранение, переход к строке | +| `src/widgets/DocumentReview/confirmBlockers.ts`, `confirmError.ts` | неразобранные строки, разбор 409 подтверждения | +| `src/widgets/DocumentReview/OsvDocument.tsx` | `DocumentOut.issues` у ОСВ-документа | +| `src/widgets/FilesDrawer/fileWarnings.ts`, `documentRows.ts`, `FilesList.tsx`, `FileWarningsPopover.tsx` | предупреждения файла в панели файлов | + +--- + +## Решения плана без обсуждения (`P6-n`) + +| Маркер | Решение | Обоснование | Цена ошибки | +|---|---|---|---| +| P6-1 | Подсказка кода — `StatementOut.edition_lines` (справочник строк редакции формы, у формы без даты — всех редакций; план 3 P3-22), затем коды строк `ok` файла, которых в справочнике нет (имя — `reference_name` ?? `line_name`); отдельного запроса нет | справочник приходит с ответом формы | нет | +| P6-2 | Якорь строки `unreadable` / `missing` — строка `ok` из источника (`row_index` не `null`) с наибольшим `row_index` меньше своего; якорь `detail` — строка `ok` из источника с `row_index = parent_row_index`; нет якоря (в том числе `detail` с `parent_row_index = null` или указывающим не на `ok`) — в начало первого раздела (после ведущих заголовков разделов; у ОФР — в начало таблицы). Вложение встаёт сразу после строки-якоря; несколько вложений одного якоря — по `row_index` | спека `arm-web` «Раскладка», S-62 («строка `detail` без родителя на экране — в начале раздела»), P3-17 (тот же порядок вложений в ответе ядра) | нет | +| P6-3 | Скрытие (пустые подстроки, расшифровки) применяется **после** раскладки, фильтром | иначе скрытый якорь сдвинул бы вложения к соседу | нет | +| P6-4 | Справочное имя (`reference_name`) — основное; файловое (`line_name`, после срезания приклеенных заголовков разделов) показывается вторым серым текстом, только если отличается после нормализации: нижний регистр, `ё` → `е`, оставлены только буквы и цифры | переносы OCR («задолжен-ность») не должны давать ложного «отличается» | лишнее или пропущенное второе имя — только показ | +| P6-5 | Маркеры «у места» строятся из **всех** записей `issues` (в том числе `relation` / `soft` — у них `line_ids` + `column_id`); список `IssuesPanel` показывает только `kind = warning`; нарушения §7 остаются списком в `ChecksPanel` (из `checks`, с акцептом) и получают ссылку «Показать» по `line_ids[0]`. Запись с `column_id` без `line_ids` (например, `COLUMN_WITHOUT_DATE`) показывается у заголовка колонки; поле шапки, которого нет в блоке шапки, и строки, которых нет в ответе, — у документа | одна запись не дублируется в двух списках; заголовок колонки — естественное место для предупреждения о дате колонки | нет | +| P6-6 | Подтверждение ячейки — в черновике; доступно, только если у строки есть `id` и у значения есть `id` (у достроенных ячеек — нет, ядро ответит `UNKNOWN_CELL`); сумма в черновике той же ячейки отменяет её подтверждение | `PATCH`: в элементе `values` ровно одно из `amount` / `confirmed`; правка суммы и так снимает `needs_review` | нет | +| P6-7 | Одно действие на строку в черновике (`code` / `detail` / `confirm`), новое заменяет прежнее; пустое поле кода и код, равный текущему, — не действие; у достроенных шаблонных строк (`id = null`) действий строки нет | 422 `INVALID_LINE_PATCH` при двух действиях; `lines[]` адресуется только `line_id` | нет | +| P6-8 | Сумма в шаблонную строку по `line_code` X и назначение кода X другой строке в одном сохранении — ошибка черновика «Код X назначается строке, а в шаблонную строку X введены суммы — сохраните по очереди», запрос не уходит | ядро ответит 422 `UNKNOWN_CELL` (P3-10), а сообщение ядра не говорит, что делать | нет | +| P6-9 | Ошибка элемента `lines[]` (409 `LINE_CODE_TAKEN`, 422 `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`, `LINE_CODE_REQUIRED`) показывается у поля кода строки `detail.line_id` (текст сервера в подсказке поля и `message.error`); строки с этим `id` на экране нет — текстом над таблицей | ядро отдаёт `detail.line_id` и `detail.line_code` (план 3 P3-24) | нет | +| P6-10 | Строка «неразобранная» для кнопки «Подтвердить данные» — `line_status ∈ {unreadable, missing}` и хотя бы у одного значения `amount !== null` или `string_value !== null` (ровно условие ядра для 409 `LINES_UNRESOLVED`) | кнопка и сервер считают одинаково | нет | +| P6-11 | Подтверждение даты отчёта — кнопка «Подтвердить дату» у поля из `needs_review_fields` (уходит `header.report_date` тем же значением); дата колонки — поле `ГГГГ-ММ-ДД` у заголовка колонки (как поле даты отчёта) с кнопкой «Подтвердить дату», если дата есть и у колонки `COLUMN_DATE_UNCERTAIN` / `COLUMN_WITHOUT_DATE` | перепечатывать ту же дату ради подтверждения неочевидно; формат ввода — как у существующего поля даты | нет | +| P6-12 | Панель файлов: у строки документа — значок с числом «предупреждения файла (`job.warnings`) + `issue_counts.error` + `issue_counts.info`» документа, красный при хотя бы одном `error` (в файле или `issue_counts.error > 0`); у строки файла без документов — только `job.warnings`; нуль — значка нет. Клик открывает список: предупреждения файла (`message` и `locator`), `job.mapping_warnings` (как `info`) и `DocumentOut.issues` документа, загружаемые при открытии (`GET /documents/{id}`) | `DocumentBrief.issue_counts` (план 3 P3-23) помечает документ без запроса на каждый; подробности — по клику | акцептованное нарушение тоже даёт значок — подробности в списке | +| P6-13 | `job.warnings` на клиенте нормализуется так же, как ядро: строка → `{code: "LEGACY_WARNING", severity: "info", message, locator: null}`, объект с `code`, `severity ∈ {error, info}`, `message` — как есть, прочее пропускается, не список → `[]` | экран не падает при ошибке порядка выкладки | нет | +| P6-14 | Сценарий T11 — на локальном стенде; строки `unreadable` / `missing` / `detail` и записи `issues` всех видов подставляются перехватом `GET` и `PATCH …/statement` (`page.route`) из фикстуры плана, пока сервисы шлют 1.x; на реальных формах 1.x — регрессия | до шага 5 выкладки 2.0-данных на стенде нет | повторный прогон на `aep_3` после шага 5 — follow-up | +| P6-15 | Цвета: ячейка `needs_review` — `#fff7e6`, исправлено — `#e6f4ff`, строка `unreadable` / `missing` целиком — `#fff1f0`, строка `ok` с `needs_review` — фон ячеек наименования и кода `#fff7e6`; подтверждённая ячейка — маленькая серая галочка перед суммой; уровень `error` — красный значок `ExclamationCircleOutlined` `#cf1322`, `info` — синий `InfoCircleOutlined` `#1677ff` | палитра уже используется на экране и в панели файлов | только вид | +| P6-16 | Ячейка кода: `ok` — код; `unreadable` — серым `line_code_text` (пусто — «—»), подсказка «Код не распознан: «<текст>»»; `missing` — серым «—», подсказка «Кода в строке нет»; `detail` — пусто, подсказка «Расшифровка — в суммы формы не входит»; у `ok` в подсказке «Уверенность кода: N%» (если меньше 100 и код не правлен), «Код подтверждён», «Код назначен аналитиком» | спека: «в ячейке кода серым — сырой текст с подсказкой» | только вид | +| P6-17 | `header.source.<поле>.confidence` — `string \| null` (строка Decimal, как прочие уверенности API, план 3 P3-20), в подсказке — `Number()` и округление до целого | одно правило для всех уверенностей | нет | +| P6-18 | `node_modules` worktree — симлинк на установленный каталог с eslint, prettier и vitest 3.1.4; поэтому вместо `make check` (он начинается с `npm install` и поставил бы пакеты в чужой каталог) гоняется `make lint typecheck test build` — те же проверки без установки | память владельца: симлинк, как в прежних срезах | нет; в описании PR сказано, что install пропущен | +| P6-19 | Предупреждения ОСВ-документа (`DocumentOut.issues`, `OSV_*`) — блок `IssuesPanel` над таблицей экрана ОСВ, все записи — у документа (строки ОСВ не адресуются `line_ids`) | спека: «предупреждения листа ОСВ … из `DocumentOut.issues[]`»; экран документа уже загружает `DocumentOut` | нет | + +## Чего не хватает в API ядра для экрана (`O-n`) + +Все пункты закрыты сведением с планом 3 (18.10.2026, его P3-20…P3-24; спека `arm-core` и гайд §5.6.1 дополнены); +маркеры сохранены. + +- **O-1.** Закрыт: `LineOut.line_code_confidence` (строка Decimal) в A8 плана 3; тип — `string | null` (T1). +- **O-2.** Закрыт: у 409 `LINE_CODE_TAKEN` и 422 `INVALID_LINE_CODE`, `UNKNOWN_LINE`, `INVALID_LINE_PATCH`, + `LINE_CODE_REQUIRED` в `detail` есть `line_id` и `line_code` (P3-24); экран ставит ошибку у поля строки (P6-9, + `lineErrorTarget`, T7). +- **O-3.** Закрыт: `StatementOut.edition_lines [{code, name}]` (P3-22), у формы без даты — коды всех редакций; подсказка + кода — из него (P6-1). +- **O-4.** Закрыт: `DocumentBrief.issue_counts {error, info}` (P3-23); панель помечает документ без запроса (P6-12, + `rowBadge`, T10). +- **O-5.** Закрыт: `header.source.<поле>.confidence` — строка Decimal (P3-20); P6-17. +- **O-6.** Закрыт: `StatementHeaderOut.okei` (P3-21); в блоке шапки поле не показывается (четыре поля T5), запись `issues` + с `field = "okei"` по-прежнему уходит в список документа (Review Focus 2). + +## Открытые вопросы владельцу + +- **В-1.** Приёмка экрана на шаге 4 выкладки — по перехвату с фикстурой (P6-14) и регрессии на формах 1.x; проверка на + реальных 2.0-данных (`aep_3`, РБ 1 кв. 2025) — после перехода сервисов (шаг 5), отдельной задачей. Устраивает? +- **В-2.** Снят 18.10.2026: `DocumentBrief.issue_counts` (план 3 P3-23) — панель помечает и документы только с + предупреждениями документа (P6-12). +- **В-3.** Подтверждённая ячейка помечается маленькой галочкой (P6-15) — в спеке и макете вида нет. Устраивает? + +--- + +## Task 0: Подготовка — worktree, issue, ветка, OpenSpec change arm-web + +**Files:** +- Create: `docs/superpowers/plans/2026-10-18-statements-v2.md` (копия этого плана), `openspec/changes/statements-v2/proposal.md`, + `design.md`, `specs/document-review-ui/spec.md`, `specs/primary-files-ui/spec.md` + +**Interfaces:** +- Produces: worktree на ветке `feat/<issue>-statements-v2`; хук require-plan пропускает правки `src/` (план с именем + ветки лежит в `docs/superpowers/plans/`); `design.md` с разделом `## Decisions made without discussion` (туда — `P6-n` и + рулинги контроллера). + +- [ ] **Step 1: Worktree и зеркальный workspace** (порядок — `~/.claude/CLAUDE.md`, «Роли инструментов»): + +```bash +WS=/Users/andrey.epifancev/projects/awp-ca +mkdir -p $WS/.wt/normalization-web +git -C $WS/arm-web fetch origin +git -C $WS/arm-web worktree add -b tmp/statements-v2 $WS/.wt/normalization-web/arm-web origin/test +git -C $WS/docs fetch origin +# до слияния arm-ka-docs#116 — ветка change; после — origin/main +git -C $WS/docs worktree add --detach $WS/.wt/normalization-web/docs origin/feat/116-unified-normalization +for l in fixtures reference-old-arm CLAUDE.md .claude arm-core; do ln -s $WS/$l $WS/.wt/normalization-web/$l; done +``` + + Пути — абсолютные (`git -C <repo> worktree add` считает относительный путь от репозитория). +- [ ] **Step 2: node_modules** (P6-18) — симлинк на каталог, где есть eslint, prettier и vitest 3.1.4: + +```bash +for c in $WS/.wt/fmt/arm-web/node_modules $WS/.wt/web-proto/arm-web/node_modules; do + [ -d $c/eslint ] && [ -d $c/prettier ] && grep -q '"version": "3.1.4"' $c/vitest/package.json && echo $c && break +done +``` + + Найденный — `ln -s <путь> $WS/.wt/normalization-web/arm-web/node_modules`. Не нашёлся — `npm install` в worktree + (свой каталог; `package-lock.json` не коммитить) и отметка `O-n` в ledger. Проверка: `cd $WS/.wt/normalization-web/arm-web + && make lint typecheck test build` — PASS на базе `test`. +- [ ] **Step 3: Issue и ветка:** + +```bash +tea issues create --login home --repo RSHB/arm-web \ + --title "Единая нормализация ББ/ОФР: экран формы и карточка файла по API v5.19 (arm-ka-docs#116)" \ + --description "Спека: arm-ka-docs, change unified-statement-normalization, specs/arm-web/spec.md. План 6: openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-6-arm-web.md. Выкладка вместе с ядром (plan 3 А+Б): JobBrief.warnings меняет тип." +git -C $WS/.wt/normalization-web/arm-web branch -m feat/<issue>-statements-v2 +cd $WS/.wt/normalization-web/arm-web && codegraph init +``` + + Все вызовы `codegraph_explore` — с `projectPath=$WS/.wt/normalization-web/arm-web`; пустой ответ — один раз отметить в + ledger и дальше Read/Grep. +- [ ] **Step 4: Копия плана** — этот файл копируется в `docs/superpowers/plans/2026-10-18-statements-v2.md` с подстановкой + номера issue вместо `<issue>` (`sed "s/<issue>/<N>/g"`); проверка: `grep -c "feat/<N>-statements-v2"` ≥ 1. +- [ ] **Step 5: Change OpenSpec** — `openspec new change statements-v2`; `proposal.md`: ссылка на change docs + (`unified-statement-normalization`, arm-ka-docs#116), что меняется (по разделу Goal), non-goals (переключатель «как в + файле», склейка форм), зависимость от ядра v5.19. Спеки-дельты (формулировки требований — из `specs/arm-web/spec.md` + docs, дословно): + - `document-review-ui`: **MODIFIED** «Форма ББ/ОФР — просмотр и правка» (подсветка «требует внимания» — по + `needs_review`; правка расширена строками, колонками, подтверждениями, наименованием организации; полный текст + требования с изменёнными фразами); **MODIFIED** «Подтверждение документа» (порядок 409 с `LINES_UNRESOLVED`, + `DUPLICATE_FORM`, отключение кнопки); **ADDED** «Раскладка строк формы со статусами», «Подсветка и предупреждения у + места», «Правка строк, колонок и подтверждение в режиме «Редактировать»», «Предупреждения ОСВ-документа» (P6-19) — + со сценариями из docs; + - `primary-files-ui`: **ADDED** «Предупреждения файла и документа в карточке файла». + `design.md` — раздел «Decisions made without discussion» с таблицей `P6-1…P6-19` (дата 2026-10-18). +- [ ] **Step 6:** `openspec validate statements-v2 --strict` — без ошибок. +- [ ] **Step 7: Commit** `openspec(statements-v2): change и план экрана формы по API v5.19 (arm-ka-docs#116, #<issue>)`. + +## Task 1: Типы API v5.19, фикстура, строки сетки + +**Files:** +- Modify: `src/types/api.ts`, `src/widgets/DocumentReview/statementGrid.ts`, `src/utils/labels.ts`, + `src/widgets/DocumentReview/statementGrid.test.ts`, `src/widgets/DocumentReview/formLayout.test.ts` (хелпер `row()`), + `src/widgets/FilesDrawer/documentRows.test.ts` (`warnings: []` — тип) +- Create: `src/widgets/DocumentReview/__fixtures__/statement-v2.json`, `src/utils/labels.test.ts` (дополнить, если есть) + +**Interfaces:** +- Produces (в `src/types/api.ts`): + +```ts +export type LineStatus = 'ok' | 'unreadable' | 'missing' | 'detail'; +export type Severity = 'error' | 'info'; +export type IssueKind = 'warning' | 'relation' | 'soft'; +export type PeriodKind = 'absolute' | 'relative:current' | 'relative:prev_year_end' | 'relative:prev_prev_year_end' + | 'relative:prev_period' | 'none'; +export interface FileWarningOut { code: string; severity: Severity; message: string; locator: string | null } +export interface IssueOut { + id: string; kind: IssueKind; code: string; severity: Severity; message: string; + line_ids: string[]; column_id: string | null; column_index: number | null; field: string | null; + source: 'service' | 'core'; relation: string | null; codes: string[]; expected: string | null; actual: string | null; +} +export interface HeaderSourceOut { text: string | null; confidence: string | null } +export interface EditionLineOut { code: string; name: string } +export interface IssueCountsOut { error: number; info: number } +export interface LineErrorDetail { code: string; message: string; line_id: string; line_code: string | null } +export interface UnresolvedLineOut { line_id: string; row_index: number | null; line_code_text: string | null } +export type CellTarget = { line_id: string } | { line_code: string }; +export type CellPatch = (CellTarget & { column_id: string; amount: string | null }) + | { line_id: string; column_id: string; confirmed: boolean }; +export type LinePatch = { line_id: string; line_code: string } | { line_id: string; line_status: 'detail' } + | { line_id: string; confirmed: boolean }; +export interface ColumnPatch { column_id: string; period_date: string } +export interface StatementPatch { + header?: { inn?: string; report_date?: string; units?: string; organization_name?: string }; + values?: CellPatch[]; columns?: ColumnPatch[]; lines?: LinePatch[]; +} +``` + + Изменения существующих: `JobBrief.warnings: FileWarningOut[]` (`mapping_warnings` — `string[]`); `DocumentOut.issues: + IssueOut[]` (`warnings: string[]` остаётся); `StatementHeaderOut` + `needs_review_fields: string[]`, `source: + Partial<Record<string, HeaderSourceOut>>`, `organization_name_modified: boolean`, `okei: string | null`; `ColumnOut` + + `period_kind: PeriodKind`, + `label_confidence: string | null`; `LineOut` + `row_index: number | null`, `source_ref: string | null`, `line_status: + LineStatus`, `line_code_text: string | null`, `line_code_confidence: string | null`, `reference_name: string | null`, + `parent_row_index: number | null`, `needs_review: boolean`, `is_confirmed: boolean`, `line_code_modified: boolean`; + `ValueOut` + `text: string | null`, `needs_review: boolean`, `is_confirmed: boolean`; `FailedCheckOut` + `codes: + string[]`, `line_ids: string[]`; `StatementOut.document.form_variant: 'full' | 'simplified' | null`; + `StatementOut.issues: IssueOut[]`, `StatementOut.edition_lines: EditionLineOut[]`; `DocumentBrief.issue_counts: + IssueCountsOut` (и `DocumentOut`, который его расширяет). +- Produces (`statementGrid.ts`): + +```ts +export type GridRow = { + key: string; lineId: string | null; code: string | null; + name: string | null; // line_name — из файла + referenceName: string | null; // справочное имя по коду + origin: 'source' | 'template'; parentCode: string | null; + lineStatus: LineStatus; rowIndex: number | null; parentRowIndex: number | null; + codeText: string | null; codeConfidence: string | null; + needsReview: boolean; isConfirmed: boolean; codeModified: boolean; + cells: Record<string, ValueOut | undefined>; +}; +export function buildStatementGrid(statement: StatementOut): { columns: ColumnOut[]; rows: GridRow[] }; +export function cellFlags(value: ValueOut | undefined): { attention: boolean; modified: boolean; confirmed: boolean }; +``` + + Ключ строки — прежний: `id`, у достроенной — `code-<код>`. `draftToPatch`, `applyDraftInput`, `emptySublineKeys`, + `cellKey` пока остаются (переезд — T6). +- Produces (`labels.ts`): `lineStatusLabel(s: LineStatus): string` — `ok` «Строка формы», `unreadable` «Код не + распознан», `missing` «Кода нет», `detail` «Расшифровка»; `severityLabel(s: Severity): string` — `error` «Ошибка», + `info` «Информация». +- Produces (фикстура `statement-v2.json`, тип `StatementOut`; у **каждой** пары «строка × колонка» есть `ValueOut`): + документ `BALANCE`, `NEEDS_CHECK`, 2025 `Q3`, `reporting_date "2025-09-30"`, `form_variant "full"`, `edition "fsbu4"`; + шапка: ИНН `7700000000` (уверенность `100`), дата отчёта `2025-09-30` (`original_value "30 сентября 2025 г."`, + уверенность `67`), единицы «тыс. руб.», `organization_name "ООО Ромашка"`, `needs_review_fields ["report_date"]`, + `okei "384"`, `source {organization_name: {text: "ООО «Ромашкa»", confidence: "67.00"}, report_date: {text: "30 + сентября 2025 г.", confidence: "67.00"}}`; колонки `c1` (`column_index 2`, «На 30 сентября 2025 г.», `2025-09-30`, `absolute`) и `c2` + (`column_index 3`, `label null`, `period_date null`, `none`); строки: + +| id | `row_index` | статус | код / `line_code_text` | наименование (`reference_name`) | `parent_row_index` | особенности (колонка `c1`) | +|---|---|---|---|---|---|---| +| `l-1110` | 0 | ok | 1110 | Нематериальные активы (то же) | — | `10` | +| `l-1150` | 1 | ok | 1150 | Основные средства (то же) | — | `70045` | +| `l-d2` | 2 | detail | — | в том числе: пригодные к использованию | 1 | `70045`, `text "70045"`, уверенность `67`, `needs_review false` | +| `l-1100` | 3 | ok | 1100 | Итого по разделу I (то же) | — | `70055` | +| `l-1210` | 4 | ok | 1210 | Запасы (то же) | — | `100` | +| `l-1230` | 5 | ok | 1230, уверенность кода `50`, строка `needs_review true` | «Дебиторская задолжен-ность» (`Дебиторская задолженность`) | — | `428311`, `text "42831 1"`, `is_recognized false`, `needs_review false` | +| `l-1240` | 6 | ok | 1240 | Финансовые вложения (то же) | — | `5` | +| `l-u7` | 7 | unreadable | `ИЧ,250` | Денежные средства | — | `50`, уверенность `67`, `needs_review true` | +| `l-m8` | 8 | missing | `null` | Прочие оборотные активы | — | `7` | +| `l-1200` | 9 | ok | 1200 | Итого по разделу II (то же) | — | `200`, `needs_review true` | +| `l-1600` | 10 | ok | 1600 | БАЛАНС (то же) | — | `70255` | +| `l-1370a` | 11 | ok | 1370 | Нераспределенная прибыль (то же) | — | `500` | +| `l-1370b` | 12 | ok | 1370 | в т.ч. прошлых лет (Нераспределенная прибыль) | — | `300` | +| `l-d13` | 13 | detail | — | справочно | `null` | `amount null`, `string_value "211(1 |"` | +| `null` | `null` | ok, `origin template` | 1250 | (Денежные средства и денежные эквиваленты) | — | все пустые, `id` значений `null` | +| `l-t1170` | `null` | ok, `origin template` | 1170 | (Финансовые вложения) | — | `3`, `is_modified true` (строка создана правкой) | + + `issues`: `i1` warning `ROW_SPLIT` info, `line_ids [l-1370a, l-1370b]`; `i2` warning `COLUMN_WITHOUT_DATE` error, + `column_id c2`; `i3` warning `ORGANIZATION_UNCERTAIN` info, `field "organization_name"`; `i4` warning `COLUMN_SKIPPED` + info, `column_index 5`, `column_id null`; `i5` warning `REPORT_DATE_UNCERTAIN` error, `field "report_date"`; `i6` + relation `RELATION_FAILED` error, `line_ids [l-1200, l-1210, l-1230, l-1240]`, `column_id c1`, `relation "1200 = 1210 + 1220 + + 1230 + 1240 + 1250 + 1260"`, `codes [...]`, `expected "155"`, `actual "200"`; `i7` warning `NAME_MISMATCH` info, source + `core`, `line_ids [l-1230]`. `checks.failed` — одно нарушение с теми же `codes`, `line_ids`, `column_index 2`. + `edition_lines` — коды ББ `fsbu4` по одному в порядке бланка с первыми именами (в том числе `{code: "1250", name: + "Денежные средства и денежные эквиваленты"}`, `{code: "1370", name: "Нераспределенная прибыль (непокрытый убыток)"}`, + `{code: "1170", name: "Финансовые вложения"}`; кода `1120` нет). + +- [ ] **Step 1: Write the failing tests** (`statementGrid.test.ts`): + - `buildStatementGrid переносит поля v2` — на фикстуре строка `l-u7`: `lineStatus 'unreadable'`, `rowIndex 7`, + `codeText 'ИЧ,250'`, `code null`, `key 'l-u7'`; `l-d2`: `parentRowIndex 1`; шаблонная 1250: `key 'code-1250'`, + `rowIndex null`, `referenceName 'Денежные средства и денежные эквиваленты'`; `l-1230`: `needsReview true`, + `codeConfidence '50'`. + - `cellFlags — внимание только по needs_review` — `{confidence: '50', is_recognized: false, needs_review: false}` → + `attention false`; `{confidence: '100', needs_review: true}` → `attention true`; `{is_confirmed: true}` → `confirmed + true`; `undefined` → все `false`. + - `labels.test.ts`: четыре подписи статуса и две уровня — дословно, как в Interfaces. +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/statementGrid.test.ts src/utils/labels.test.ts` — FAIL. +- [ ] **Step 3: Implement** типы, фикстуру, `GridRow`, `cellFlags`, подписи; хелперы существующих тестов (`row()` в + `formLayout.test.ts`, `v()` в `statementGrid.test.ts`, `job` в `documentRows.test.ts`) дополняются полями по умолчанию + (`lineStatus 'ok'`, `rowIndex` по порядку, `needs_review false` и т. д.), ожидания старых тестов не меняются, кроме + теста `cellFlags` по уверенности — он заменяется новым. +- [ ] **Step 4:** `make lint typecheck test build` — PASS (компоненты, читающие `job.warnings` как строки, на `test` нет). +- [ ] **Step 5: Commit** `feat(types): API формы v5.19 — статусы строк, needs_review, issues, предупреждения файла (#<issue>)`. + +## Task 2: Раскладка — вложения строк без кода и расшифровок, фильтр показа, имена + +**Files:** +- Modify: `src/widgets/DocumentReview/formLayout.ts`, `src/widgets/DocumentReview/formLayout.test.ts` + +**Interfaces:** +- Consumes: `GridRow` (T1). +- Produces: + +```ts +export type LayoutRow = + | { kind: 'section'; key: string; title: string; level: 1 | 2 } + | { kind: 'line'; key: string; row: GridRow; total: boolean; grand: boolean; + child: boolean; // подстрока под родителем или расшифровка — отступ + displayName: string; // справочное имя, иначе файловое без приклеенных заголовков + secondaryName: string | null; // файловое имя, если отличается (P6-4) + fullName: string | null }; // сырое line_name для подсказки, если показано не оно +export function formLayout(type: DocumentType, rows: GridRow[]): LayoutRow[]; +export function filterLayout(layout: LayoutRow[], opts: { showDetails: boolean; hiddenKeys: ReadonlySet<string> }): LayoutRow[]; +export function detailCount(rows: GridRow[]): number; +``` + + `formLayout` раскладывает по разделам только строки `lineStatus === 'ok'` (как сейчас), одинаковые коды — по + `rowIndex` (`null` последним), подстроки под родителем — по коду, затем `rowIndex`; затем вставляет вложения по P6-2. + Расшифровки возвращаются всегда; скрывает их `filterLayout`. + +- [ ] **Step 1: Write the failing tests** (`shape` в тесте: раздел — `# <заголовок>`, строка `ok` — код, `unreadable` / + `missing` — `?<codeText ?? '-'>`, `detail` — `> <name>`): + - `строка без кода под ближайшей предыдущей по row_index` — `1230 r0, 1240 r1, unreadable 'ИЧ,250' r2, 1250 r3` → + в разделе II: `1230`, `1240`, `?ИЧ,250`, `1250`; + - `якорь по файлу, а не по коду` — `1250 r0, unreadable r1, 1210 r2` → `1210`, `1250`, `?…`; + - `нет якоря — в начало первого раздела` — `missing r0, 1110 r1` (ББ) → `['# АКТИВ', '# I. Внеоборотные активы', '?-', + '1110', …]`; ОФР `missing r0, 2110 r1` → первая строка `?-`; + - `расшифровки под родителем по row_index с отступом` — `1150 r4, d r6 (parent 4), d r5 (parent 4)` → `1150`, `> r5`, + `> r6`, у обеих `child true`; + - `расшифровка без родителя — в начало первого раздела` — `parentRowIndex null` и `parentRowIndex 99` (нет такой + строки); + - `повтор кода: по row_index, вложение у своего экземпляра` — `2410 r9, 2410 r6, unreadable r7` (ОФР) → `2410` (r6), + `?…`, `2410` (r9) — Review Focus 1; + - `шаблонная строка не якорь` — `1250 (rowIndex null, template)`, `unreadable r1` после `1210 r0` → вложение под 1210; + - `якорь — пятизначная подстрока` — `1150 r0, 11501 r1 (parentCode 1150), unreadable r2` → `1150`, `11501`, `?…`; + - `filterLayout скрывает расшифровки и пустые подстроки, вложение скрытого якоря остаётся` — `showDetails false` убирает + `> …`; `hiddenKeys {11501}` убирает `11501`, `?…` остаётся после `1150` — Review Focus 1; + - `detailCount` — фикстура → `2`; форма без `detail` → `0` (Review Focus 4); + - `имена` — `referenceName 'Дебиторская задолженность'`, `name 'Дебиторская задолжен-ность'` → `displayName` справочное, + `secondaryName null`; `name 'Прочие оборотные'` → `secondaryName 'Прочие оборотные'`; `referenceName 'Гудвил'`, + `name 'АКТИВ I. Внеоборотные активы Гудвил'` → `secondaryName null`, `fullName` = сырое имя; строка без + `referenceName` — `displayName` из `name`, `secondaryName null`; + - `фикстура целиком` — порядок: `# АКТИВ`, `# I. Внеоборотные активы`, `> справочно` (l-d13 без родителя), `1110`, + `1150`, `> в том числе…`, `1170`, `1100`, `# II. Оборотные активы`, `1210`, `1230`, `1240`, `?ИЧ,250`, `?-`, `1250`, + `1200`, `1600`, `# ПАССИВ`, `# III. Капитал и резервы`, `1370` (a), `1370` (b). +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/formLayout.test.ts` — FAIL. +- [ ] **Step 3: Implement.** Вставка вложений — отдельной внутренней функцией после построения базовой раскладки: + +```text +anchorKey(a) = detail → ключ строки ok из источника с rowIndex === a.parentRowIndex, иначе null + иначе → ключ строки ok из источника с max rowIndex < a.rowIndex, иначе null (a.rowIndex null → null) +группы по anchorKey, внутри — по rowIndex (null последним), затем key +обход базы: после строки с ключом-якорем — её группа; группа null — после ведущих строк kind = 'section' +``` + + Имена (P6-4): нормализация для сравнения — `toLowerCase`, `ё` → `е`, удалить всё, кроме букв и цифр. +- [ ] **Step 4:** тот же запуск — PASS; `make lint typecheck test build` — PASS. +- [ ] **Step 5: Commit** `feat(statement): раскладка строк без кода и расшифровок на своих местах (#<issue>)`. + +## Task 3: Таблица формы — вид строк и ячеек, переключатель расшифровок + +**Files:** +- Create: `src/widgets/DocumentReview/cellView.ts`, `cellView.test.ts`, `CodeCell.tsx`, `ValueCell.tsx` +- Modify: `src/widgets/DocumentReview/StatementTable.tsx`, `StatementReview.tsx` + +**Interfaces:** +- Consumes: `formLayout`, `filterLayout`, `detailCount` (T2), `cellFlags` (T1). +- Produces: + +```ts +export const COLORS: { attention: '#fff7e6'; modified: '#e6f4ff'; unresolved: '#fff1f0'; section: '#f2f4f7'; grand: '#f9f0ff' }; +export function valueBackground(value: ValueOut | undefined): string | undefined; // modified → attention → нет +export function rowBackground(row: GridRow): string | undefined; // unresolved для unreadable/missing; attention для ok с needsReview (только наименование и код) +export function cellTipLines(value: ValueOut, issues?: IssueOut[]): string[]; +export function codeCellView(row: GridRow): { text: string; muted: boolean; tip: string | undefined }; +``` + + `StatementTable` получает готовые `layout: LayoutRow[]` (раскладку и фильтр считает `StatementReview` в `useMemo`), а не + `rows`. Строка таблицы получает `id="row-<key>"` (`onRow`) — для перехода к строке (T4, T8). + +- [ ] **Step 1: Write the failing tests** (`cellView.test.ts`): + - `cellTipLines` — значение `{text: '42831 1', confidence: '100', is_recognized: false}` → `['Распознано: «42831 1»', + 'Уверенность: 100%']`; исправленное `{is_modified: true, original_amount: '293709'}` → первой строкой `` `Исходное: ${formatAmount('293709', { fraction: 'auto', empty: '-' })}` `` (как сейчас); + `{is_confirmed: true}` → содержит `'Подтверждено аналитиком'`; `text null`, `confidence null` → `[]`; значение + строки `detail` с `needs_review false`, `text '70045'`, `confidence '67'` → `['Распознано: «70045»', 'Уверенность: 67%']` + (подсказка есть и без подсветки, Р-23); + - `codeCellView` — тексты P6-16 дословно для `ok` (с уверенностью 50 → `'Уверенность кода: 50%'`; `codeModified true` → + `'Код назначен аналитиком'`, уверенности нет), `unreadable 'ИЧ,250'` → `{text: 'ИЧ,250', muted: true, tip: 'Код не + распознан: «ИЧ,250»'}`, `missing` → `{text: '—', muted: true, tip: 'Кода в строке нет'}`, `detail` → `{text: '', + muted: true, tip: 'Расшифровка — в суммы формы не входит'}`; + - `valueBackground` / `rowBackground` — `needs_review true` → `#fff7e6`, `is_modified true` → `#e6f4ff` (перекрывает), + `confidence '50'` без `needs_review` → `undefined`; строка `unreadable` → `#fff1f0`, `ok` с `needsReview` → `#fff7e6`. +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/cellView.test.ts` — FAIL. +- [ ] **Step 3: Implement** функции; компоненты: `CodeCell` (просмотр — `codeCellView`; серым — `Typography.Text + type='secondary'`), `ValueCell` (сумма, галочка подтверждения `CheckOutlined` 10px серым, подсказка — строки + `cellTipLines` через `<br/>`); `StatementTable`: колонка наименования — `displayName`, ниже серым `secondaryName`, + подсказка `fullName`; фон строки `unreadable` / `missing` — во всех ячейках строки; `StatementReview`: переключатель + «Показать расшифровки (N)» (`Switch`, как «Показать пустые строки детализации»), показывается при `detailCount > 0`; + пустые подстроки по-прежнему считает `emptySublineKeys` — теперь как `hiddenKeys` для `filterLayout`. +- [ ] **Step 4:** `make lint typecheck test build` — PASS. +- [ ] **Step 5: Сверка с макетом.** `make start`; экран ББ на стенде (форма 1.x) и с перехватом фикстуры (рецепт — T11, + шаг 2): окно 1600×1000, скриншот против `docs/mockup/screens/41-document-check.png` — разделы, итоги, «БАЛАНС» + фиолетовым, строка «ИЧ,250» под 1240 красноватым фоном с серым кодом, переключатель «Показать расшифровки (2)»; + расхождения — в ledger (`O-n`). +- [ ] **Step 6: Commit** `feat(statement): вид строк по статусам, подсветка по needs_review, переключатель расшифровок (#<issue>)`. + +## Task 4: Предупреждения и нарушения у места + +**Files:** +- Create: `src/widgets/DocumentReview/issuePlaces.ts`, `issuePlaces.test.ts`, `IssuesPanel.tsx` +- Modify: `StatementTable.tsx`, `ValueCell.tsx`, `StatementReview.tsx`, `ChecksPanel.tsx` + +**Interfaces:** +- Consumes: `GridRow`, `ColumnOut`, `IssueOut` (T1); `columnTitle` (`reviewView.ts`); `lineStatusLabel`, `severityLabel` (T1). +- Produces: + +```ts +export const HEADER_BLOCK_FIELDS = ['organization_name', 'inn', 'report_date', 'units'] as const; +export interface IssuePlaces { + byRow: Map<string, IssueOut[]>; byCell: Map<string, IssueOut[]>; // ключ ячейки — cellKey(rowKey, columnId) + byColumn: Map<string, IssueOut[]>; byField: Map<string, IssueOut[]>; document: IssueOut[]; +} +export function issuePlaces(issues: IssueOut[], rows: GridRow[], columns: ColumnOut[], + fields?: readonly string[] /* по умолчанию HEADER_BLOCK_FIELDS */): IssuePlaces; +export interface IssueListItem { id: string; severity: Severity; message: string; place: string; targetKey: string | null } +export function issueListItems(issues: IssueOut[], places: IssuePlaces, rows: GridRow[], columns: ColumnOut[]): IssueListItem[]; +export function worstSeverity(issues: IssueOut[]): Severity | null; +``` + + Правила `issuePlaces` (P6-5): `field` из `fields` → `byField`, `field` вне `fields` → `document`; иначе непустые + `line_ids` → известные строки (по `lineId`); ни одной известной → `document`; есть `column_id` известной колонки → + `byCell` для каждой известной строки, иначе `byRow`; без `line_ids` с известным `column_id` → `byColumn`; всё прочее + (в том числе `column_index` без `column_id`) → `document`. `issueListItems` — только `kind === 'warning'`, сначала + `error`, затем `info`, внутри — по порядку `issues`; `place`: строка `ok` — «строка <код>», `unreadable` / `missing` — + «строка без кода «<codeText>»» (без текста — «строка без кода»), `detail` — «расшифровка «<name>»»; ячейка — «<строка>, + колонка «<columnTitle>»»; колонка — «колонка «<columnTitle>»»; поле — «поле «<подпись поля>»»; документ — «документ», + с `column_index` без `column_id` — «столбец <column_index + 1> вне колонок формы»; `targetKey` — ключ первой строки места + (иначе `null`). + +- [ ] **Step 1: Write the failing tests** (`issuePlaces.test.ts`, на фикстуре): + - `привязка фикстуры` — `i1` в `byRow` у `l-1370a` и `l-1370b`; `i6` в `byCell` у четырёх ячеек `c1`; `i2` в + `byColumn.c2`; `i3`, `i5` в `byField`; `i4` в `document`; `i7` в `byRow.l-1230`; + - `неизвестные строки и поля — у документа` — `line_ids ['l-gone']` → `document`; `line_ids ['l-gone', 'l-1210']` → + только `l-1210`; `field 'okpo'` → `document` — Review Focus 2; + - `issueListItems` — на фикстуре 6 пунктов (без `i6`), первыми `i2` и `i5` (`error`); `i4.place === 'столбец 6 вне + колонок формы'`, `targetKey null`; `i1.place === 'строка 1370'`, `targetKey 'l-1370a'`; запись у `l-d13` → `place + 'расшифровка «справочно»'`; + - `worstSeverity` — `[info, error]` → `'error'`; `[]` → `null`. +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/issuePlaces.test.ts` — FAIL. +- [ ] **Step 3: Implement** функции и UI: значок уровня (P6-15) в ячейке наименования (`byRow`), в углу ячейки значения + (`byCell`, сообщения — в её подсказке после `cellTipLines`), в заголовке колонки (`byColumn`); `IssuesPanel` — `Alert` + «Предупреждения (N)» (тип `error`, если есть `error`, иначе `info`), список с прокруткой (`maxHeight 120`, как + `failureList`), у пункта — тег уровня (`severityLabel`), текст, место и ссылка «Показать» при `targetKey`; + `ChecksPanel` — ссылка «Показать» у нарушения по `line_ids[0]`. Переход к строке — функция `focusRow(key)` в + `StatementReview`: если строка — скрытая расшифровка или скрытая пустая подстрока, сначала включает соответствующий + переключатель; после отрисовки `document.getElementById('row-' + key)?.scrollIntoView({block: 'center'})` и подсветка + рамкой на 2 с (Review Focus 2). +- [ ] **Step 4:** `make lint typecheck test build` — PASS; на перехвате фикстуры: «Показать» у `ROW_SPLIT` прокручивает к + 1370; у записи на `l-d13` при выключенных расшифровках — включает их. +- [ ] **Step 5: Commit** `feat(statement): предупреждения и нарушения у строк, ячеек, колонок и в списке документа (#<issue>)`. + +## Task 5: Шапка формы — наименование организации, поля на проверку, подсказки источника + +**Files:** +- Modify: `src/widgets/DocumentReview/headerFields.ts`, `headerFields.test.ts`, `StatementHeaderBlock.tsx` + +**Interfaces:** +- Consumes: `StatementHeaderOut` (T1), `IssuePlaces.byField` (T4). +- Produces: + +```ts +export type HeaderFieldKey = 'organization_name' | 'inn' | 'report_date' | 'units'; +export type HeaderFieldRow = { field: HeaderFieldKey; label: string; text: string; raw: string | null; + attention: boolean; modified: boolean; tip: string | undefined; issues: IssueOut[]; canConfirm: boolean }; +export function headerFields(header: StatementHeaderOut, byField?: Map<string, IssueOut[]>): HeaderFieldRow[]; +``` + + Порядок и подписи: «Организация», «ИНН», «Дата отчёта», «Единица измерения». `attention` — поле в + `needs_review_fields` (только); `modified` — `organization_name_modified` или `HeaderFieldOut.modified`; `tip`: + исправлено → «Исходное: <original_value>» (у организации — «Распознано: «<source.organization_name.text>»»), иначе при + наличии `source[field]` — «Распознано: «<text>» · уверенность N%» (`Number(confidence)`, P6-17); `canConfirm` — + `field === 'report_date'` ∧ поле в `needs_review_fields` ∧ `raw !== null`. + +- [ ] **Step 1: Write the failing tests** (`headerFields.test.ts`, фикстура): + - `четыре поля по порядку` — `['organization_name', 'inn', 'report_date', 'units']`, подписи дословно; + - `внимание только по needs_review_fields` — дата отчёта `attention true`, `canConfirm true`; ИНН с уверенностью `50` + вне `needs_review_fields` → `attention false`; пустое ИНН вне списка → `attention false`; + - `подсказка источника` — организация: `tip === 'Распознано: «ООО «Ромашкa»» · уверенность 67%'`; `confidence` строкой + `'67.00'` — тот же текст `67%`; + - `исправленная организация` — `organization_name_modified true` → `modified true`, `tip` с текстом источника; + - `предупреждения поля` — `byField` с `i3` → `issues` у организации — `[i3]`. +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/headerFields.test.ts` — FAIL. +- [ ] **Step 3: Implement**; `StatementHeaderBlock` рисует строку «Организация» шире остальных (ячейка `minWidth 320`), + значки уровня из `issues`, в режиме правки — `Input` у всех четырёх полей и кнопку «Подтвердить дату» при `canConfirm` + (черновик — T6, T8). Блок по-прежнему справа над заголовком формы, как в `41-document-check.png`. +- [ ] **Step 4:** `make lint typecheck test build` — PASS. +- [ ] **Step 5: Commit** `feat(statement): шапка — организация, поля на проверку и подсказки распознанного текста (#<issue>)`. + +## Task 6: Черновик правок и один PATCH + +**Files:** +- Create: `src/widgets/DocumentReview/statementDraft.ts`, `statementDraft.test.ts` +- Modify: `src/widgets/DocumentReview/statementGrid.ts` (удалить `draftToPatch`; `applyDraftInput`, `emptySublineKeys` + остаются), `statementGrid.test.ts` (тесты `draftToPatch` переезжают), `src/utils/dates.ts`, `src/utils/dates.test.ts`, + `StatementReview.tsx`, `StatementTable.tsx`, `StatementHeaderBlock.tsx` + +**Interfaces:** +- Consumes: `GridRow`, `cellKey` (T1), `HeaderFieldKey` (T5), `parseAmountInput` (`utils/money.ts`). +- Produces: + +```ts +// utils/dates.ts +export function isIsoDate(text: string): boolean; // строго ГГГГ-ММ-ДД и существующая дата +// statementDraft.ts +export type LineAction = { kind: 'code'; code: string } | { kind: 'detail' } | { kind: 'confirm'; confirmed: boolean }; +export type HeaderDraft = Partial<Record<HeaderFieldKey, string>> & { confirm_report_date?: boolean }; +export interface StatementDraft { + amounts: Record<string, string>; // cellKey → введённый текст + confirms: Record<string, boolean>; // cellKey → confirmed + lines: Record<string, LineAction>; // lineId → действие + columns: Record<string, string>; // columnId → введённая дата + header: HeaderDraft; +} +export const emptyDraft = (): StatementDraft => ({ amounts: {}, confirms: {}, lines: {}, columns: {}, header: {} }); +export function isDraftEmpty(draft: StatementDraft): boolean; +export type DraftError = { kind: 'amount' | 'code' | 'date' | 'header' | 'conflict'; key: string; message: string }; +export function buildStatementPatch(draft: StatementDraft, ctx: { rows: GridRow[]; header: StatementHeaderOut }): + { ok: true; body: StatementPatch | null } | { ok: false; errors: DraftError[] }; +``` + + Правила сборки: `values` — суммы (строка с `id` → `line_id`, достроенная → `line_code`, как прежний `draftToPatch`), + затем подтверждения ячеек без суммы в черновике (P6-6); `lines` — действия по P6-7 (код — `^\d{4,5}$`, иначе ошибка + `code` «Код — 4 или 5 цифр»); `columns` — непустые даты, `isIsoDate`, иначе ошибка `date` «Дата в формате ГГГГ-ММ-ДД»; + `header` — `inn`, `report_date`, `units`, `organization_name` после `trim`, непустые (пустое — не отправляется); + `organization_name` длиннее 1000 — ошибка `header`; `confirm_report_date` без введённой даты → `report_date` = текущее + значение; конфликт P6-8 — ошибка `conflict` с `key` строки действия. Нет ни одной части → `body: null`. + +- [ ] **Step 1: Write the failing tests** (`statementDraft.test.ts`, фикстура; `dates.test.ts`): + - `суммы как раньше` — `l-1210:c1 = '120'` → `values [{line_id: 'l-1210', column_id: 'c1', amount: '120'}]`; шаблонная + `code-1250:c1 = '5'` → `{line_code: '1250', …}`; `'12,3,4'` → ошибка `amount`; + - `подтверждение ячейки` — `confirms {l-u7:c1: true}` → `{line_id: 'l-u7', column_id: 'c1', confirmed: true}`; та же + ячейка и в `amounts` → только элемент с `amount` (Review Focus 3); + - `действия строки` — `{l-u7: code '1250'}` → `lines [{line_id: 'l-u7', line_code: '1250'}]`; `{l-m8: detail}` → + `{line_id: 'l-m8', line_status: 'detail'}`; `{l-1230: confirm true}` → `{line_id: 'l-1230', confirmed: true}`; код + `''` → `body null`; код `'1230'` у `l-1230` → `body null`; код `'12a4'` и `'123'` → ошибка `code` с `key 'l-u7'`; + `'11501'` → проходит; + - `действие строки и сумма той же строки` — `{l-m8: detail}` и `l-m8:c1 = '8'` → оба в теле (`lines` и `values`); + - `конфликт шаблонной суммы и кода` — `code-1250:c1 = '5'` и `{l-u7: code '1250'}` → ошибка `conflict`, текст P6-8 + дословно с `1250` — Review Focus 3; + - `колонки` — `{c2: '2024-12-31'}` → `columns [{column_id: 'c2', period_date: '2024-12-31'}]`; `'31.12.2024'` и + `'2024-02-30'` → ошибка `date`; `''` → не отправляется; + - `шапка` — `organization_name ' ООО Ромашка '` → `'ООО Ромашка'`; 1001 символ → ошибка `header`; `confirm_report_date` + → `report_date '2025-09-30'`; `confirm_report_date` и введённая `'2025-06-30'` → `'2025-06-30'`; `inn ''` → нет `inn`; + - `пустой черновик` — `isDraftEmpty(emptyDraft()) === true`, `buildStatementPatch` → `{ok: true, body: null}`; + - `isIsoDate` — `'2024-12-31'` да; `'2024-2-1'`, `'2024-02-30'`, `'31.12.2024'`, `''` нет. +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/statementDraft.test.ts src/utils/dates.test.ts` — FAIL. +- [ ] **Step 3: Implement**; `StatementReview` держит одно состояние `draft: StatementDraft` вместо `draft` и `header`; + `save()` вызывает `buildStatementPatch`: ошибки — `message.error` с первой ошибкой и красная рамка у полей (ключи + ошибок передаются в таблицу и шапку), `body null` — выход из режима правки без запроса, иначе один `patchStatement`; + `dirty` = `!isDraftEmpty(draft)`. Поведение сумм и шапки (ИНН, дата, единицы) не меняется — сценарии W21–W27 + `qa-guide` §8.5 проходят. +- [ ] **Step 4:** `make lint typecheck test build` — PASS. +- [ ] **Step 5: Commit** `refactor(statement): один черновик правок и сборка единственного PATCH (#<issue>)`. + +## Task 7: Режим «Редактировать» — коды строк, меню строки, LINE_CODE_TAKEN + +**Files:** +- Create: `src/widgets/DocumentReview/codeSuggestions.ts`, `codeSuggestions.test.ts`, `lineMenu.ts`, `lineMenu.test.ts`, + `LineMenu.tsx` +- Modify: `CodeCell.tsx`, `StatementTable.tsx`, `StatementReview.tsx` + +**Interfaces:** +- Consumes: `GridRow`, `EditionLineOut`, `LineErrorDetail` (T1), `StatementDraft`, `LineAction` (T6). +- Produces: + +```ts +// codeSuggestions.ts +export type CodeOption = { code: string; name: string }; +export function codeOptions(editionLines: EditionLineOut[], rows: GridRow[]): CodeOption[]; + // P6-1: сначала edition_lines по порядку, затем коды строк ok файла вне справочника (name = referenceName ?? name ?? ''), без повторов +export function filterCodeOptions(options: CodeOption[], query: string, limit?: number /* 20 */): CodeOption[]; +export function lineErrorTarget(error: unknown): { lineId: string; lineCode: string | null } | null; + // P6-9: detail.line_id / detail.line_code ошибки элемента lines[] (409 LINE_CODE_TAKEN, 422 INVALID_LINE_CODE, + // INVALID_LINE_PATCH, UNKNOWN_LINE, LINE_CODE_REQUIRED); иная ошибка или нет line_id — null +// lineMenu.ts +export type LineMenuKey = 'mark_detail' | 'assign_code' | 'change_code' | 'confirm_code' | 'unconfirm_code' | 'reset'; +export function lineMenuItems(row: GridRow, action: LineAction | undefined): { key: LineMenuKey; label: string }[]; +export function applyLineMenu(row: GridRow, key: LineMenuKey, lines: Record<string, LineAction>): Record<string, LineAction>; +export function showsCodeInput(row: GridRow, action: LineAction | undefined): boolean; +``` + + Пункты (дословно): `unreadable` / `missing` — «Это расшифровка» (`mark_detail`); `detail` — «Назначить код» + (`assign_code` → действие `{kind: 'code', code: ''}`); `ok` из источника — «Сделать расшифровкой» (`mark_detail`), + «Сменить код» (`change_code` → `{kind: 'code', code: <текущий>}`), «Подтвердить код» (`confirm_code`, при `needsReview` и + не `isConfirmed`), «Снять подтверждение кода» (`unconfirm_code`, при `isConfirmed`); при наличии действия — «Отменить + изменение строки» (`reset`); строка с `lineId === null` — пусто. Поле кода видно у `unreadable` / `missing` всегда и у + любой строки с действием `code`. + +- [ ] **Step 1: Write the failing tests:** + - `codeOptions` (фикстура) — первые элементы — `edition_lines` по порядку; `{code: '1250', name: 'Денежные средства и + денежные эквиваленты'}` и `1370` — по одному разу; код строки `ok` файла вне справочника (добавить в тест строку + `ok` 1231) — в конце, с её именем; у формы с `edition_lines: []` — только коды строк `ok`; нет строк `unreadable` / + `detail`; + - `filterCodeOptions` — `'12'` → коды на `12…`; `'денеж'` → 1250; `'ДЕНЁЖ'` → 1250 (`ё` = `е`, регистр не важен); `''` → + первые 20; + - `lineMenuItems` — `l-u7` → `['mark_detail']`; `l-d2` → `['assign_code']`; `l-1230` (`needsReview`) → `['mark_detail', + 'change_code', 'confirm_code']`; `l-1230` с `isConfirmed` → `['mark_detail', 'change_code', 'unconfirm_code']`; при + действии — последним `reset`; шаблонная 1250 → `[]`; подписи дословно; + - `applyLineMenu` — `confirm_code` → `{kind: 'confirm', confirmed: true}`; `reset` убирает ключ; новое действие заменяет + прежнее; + - `lineErrorTarget` — `ApiError` 409 `{code: 'LINE_CODE_TAKEN', message, line_id: 'l-u7', line_code: '1170'}` → + `{lineId: 'l-u7', lineCode: '1170'}`; 422 `INVALID_LINE_PATCH` с `line_code: null` → `{lineId, lineCode: null}`; 409 + `LINES_UNRESOLVED` → `null`; 422 массивом (pydantic) → `null`. +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/codeSuggestions.test.ts src/widgets/DocumentReview/lineMenu.test.ts` — FAIL. +- [ ] **Step 3: Implement**; `CodeCell` в режиме правки при `showsCodeInput` — `AutoComplete` (ширина колонки кода в + режиме правки — 160) с опциями «<код> — <имя>», значение — код; ошибка формата или строка `lineErrorTarget` ответа — `status='error'` + с подсказкой; у строки с действием `detail` — серый тег «→ расшифровка», с `confirm` — «→ код подтверждён» / + «→ подтверждение снято». `LineMenu` — кнопка `MoreOutlined` в ячейке наименования (`Dropdown`, `trigger click`), + только в режиме правки. Ответ на сохранение с ошибкой элемента `lines[]` (409 `LINE_CODE_TAKEN`, 422 коды строк из + Global Constraints): `lineErrorTarget` — красное поле кода этой строки с текстом сервера и `message.error`; строки на + экране нет — тот же текст над таблицей (P6-9). Прочие 422 — `message.error(e.message)`; черновик сохраняется. +- [ ] **Step 4:** `make lint typecheck test build` — PASS; на перехвате фикстуры: ввести 1250 у «ИЧ,250» → перехваченное + тело `PATCH` содержит `lines: [{line_id: 'l-u7', line_code: '1250'}]` и только его. +- [ ] **Step 5: Commit** `feat(statement): правка строк — код с подсказкой из справочника, меню строки, LINE_CODE_TAKEN (#<issue>)`. + +## Task 8: Подтверждение ячеек, даты колонок, правка шапки + +**Files:** +- Create: `src/widgets/DocumentReview/ColumnHeader.tsx`, `src/widgets/DocumentReview/editRules.ts`, `editRules.test.ts` +- Modify: `ValueCell.tsx`, `StatementTable.tsx`, `StatementHeaderBlock.tsx`, `StatementReview.tsx` + +**Interfaces:** +- Consumes: `StatementDraft` (T6), `IssuePlaces.byColumn` (T4), `HeaderFieldRow.canConfirm` (T5). +- Produces: + +```ts +export const COLUMN_DATE_CODES: ReadonlySet<string>; // COLUMN_WITHOUT_DATE, COLUMN_DATE_UNCERTAIN +export function columnDateEditable(col: ColumnOut, columnIssues: IssueOut[]): { editable: boolean; canConfirm: boolean }; +export function cellConfirmState(row: GridRow, value: ValueOut | undefined, draft: StatementDraft, key: string): + 'confirm' | 'unconfirm' | 'pending-confirm' | 'pending-unconfirm' | null; +``` + + `columnDateEditable`: `editable` — (`period_kind === 'none'` ∧ `period_date === null`) или у колонки запись из + `COLUMN_DATE_CODES`; `canConfirm` — `editable` ∧ `period_date !== null` ∧ запись из `COLUMN_DATE_CODES` есть. + `cellConfirmState`: `null`, если у строки или значения нет `id` или в черновике есть сумма этой ячейки; черновик + `confirms[key]` → `pending-*`; иначе `needs_review` → `confirm`, `is_confirmed` → `unconfirm`, прочее — `null`. + +- [ ] **Step 1: Write the failing tests** (`editRules.test.ts`, фикстура): + - `columnDateEditable` — `c2` с `i2` → `{editable: true, canConfirm: false}`; `c1` без записей → `{false, false}`; `c1` с + записью `COLUMN_DATE_UNCERTAIN` → `{true, true}`; + - `cellConfirmState` — `l-u7:c1` (`needs_review`) → `'confirm'`; с `confirms[key] = true` → `'pending-confirm'`; с + суммой в черновике → `null`; ячейка шаблонной 1250 → `null`; значение `is_confirmed true` → `'unconfirm'`. +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/editRules.test.ts` — FAIL. +- [ ] **Step 3: Implement**; `ValueCell` в режиме правки — рядом с полем суммы маленькая кнопка: `confirm` — `CheckOutlined` + «Подтвердить значение», `unconfirm` — `UndoOutlined` «Снять подтверждение», `pending-*` — та же кнопка нажатой + (повторный клик убирает из черновика); `ColumnHeader` — подпись `columnTitle`, значки `byColumn`, в режиме правки при + `editable` — `Input` `ГГГГ-ММ-ДД` (значение — черновик или `period_date`) и «Подтвердить дату» при `canConfirm` (кладёт + в черновик текущую дату); шапка — ввод организации и «Подтвердить дату» (`confirm_report_date`). +- [ ] **Step 4:** `make lint typecheck test build` — PASS; на перехвате: подтвердить ячейку «ИЧ,250», дату `c2` + `2024-12-31`, «Подтвердить дату» отчёта, организацию «ООО Ромашка» — одно сохранение, в теле `values[{…confirmed: + true}]`, `columns`, `header {report_date: '2025-09-30', organization_name: 'ООО Ромашка'}`. +- [ ] **Step 5: Commit** `feat(statement): подтверждение ячеек, дата колонки, наименование организации и подтверждение даты (#<issue>)`. + +## Task 9: «Подтвердить данные» — неразобранные строки и новые 409 + +**Files:** +- Create: `src/widgets/DocumentReview/confirmBlockers.ts`, `confirmBlockers.test.ts` +- Modify: `src/widgets/DocumentReview/confirmError.ts`, `confirmError.test.ts`, `StatementReview.tsx` + +**Interfaces:** +- Consumes: `GridRow` (T1), `focusRow` (T4), `UnresolvedLineOut` (T1). +- Produces: + +```ts +export function unresolvedLines(rows: GridRow[]): GridRow[]; // P6-10, по rowIndex +export const unresolvedHint = (n: number): string => `Разберите строки без кода: ${n}`; +// confirmError.ts +export function confirmErrorInfo(e: unknown): { code: string | undefined; message: string; failures: FailedCheckOut[]; + lines: UnresolvedLineOut[]; documentId: string | null }; +``` + +- [ ] **Step 1: Write the failing tests:** + - `unresolvedLines` (фикстура) — `['l-u7', 'l-m8']`; строка `missing` без значений — не входит; `unreadable` только со + `string_value '211(1 |'` — входит; `detail` с суммой — не входит; форма 1.x (все `ok`) — `[]` (Review Focus 4); + - `unresolvedHint(2) === 'Разберите строки без кода: 2'`; + - `confirmErrorInfo` — `ApiError(409, 'LINES_UNRESOLVED', …, {detail: {lines: [{line_id: 'l-m8', row_index: 8, + line_code_text: null}]}})` → `lines` из одного элемента; `DUPLICATE_FORM` с `detail.document_id 'doc-0'` → + `documentId 'doc-0'`; `CHECKS_NOT_PASSED` — `failures` как раньше; не `ApiError` — прежний текст, `lines []`, + `documentId null`. +- [ ] **Step 2:** `npx vitest run src/widgets/DocumentReview/confirmBlockers.test.ts src/widgets/DocumentReview/confirmError.test.ts` — FAIL. +- [ ] **Step 3: Implement**; кнопка «Подтвердить данные» отключена при `unresolvedLines(rows).length > 0` (вдобавок к + `reviewActions`), обёрнута в `Tooltip` с `Typography.Link` «Разберите строки без кода: N» → `focusRow(первая.key)`; + ответ 409: `LINES_UNRESOLVED` — красный блок с текстом `unresolvedHint(detail.lines.length)` и ссылкой к первой строке + по `line_id`; `DUPLICATE_FORM` — текст сервера и ссылка «Открыть подтверждённую форму» (`navigate('/documents/' + + documentId)`); `PERIOD_REQUIRED` и `CHECKS_NOT_PASSED` — как сейчас. +- [ ] **Step 4:** `make lint typecheck test build` — PASS; на перехвате: кнопка отключена, подсказка «Разберите строки + без кода: 2», клик ведёт к «ИЧ,250»; перехват `POST …/confirm` с 409 `DUPLICATE_FORM` даёт ссылку. +- [ ] **Step 5: Commit** `feat(statement): блокировка подтверждения при строках без кода, LINES_UNRESOLVED и DUPLICATE_FORM (#<issue>)`. + +## Task 10: Карточка файла и ОСВ — предупреждения структурами + +**Files:** +- Create: `src/widgets/FilesDrawer/fileWarnings.ts`, `fileWarnings.test.ts`, `FileWarningsPopover.tsx` +- Modify: `src/widgets/FilesDrawer/documentRows.ts`, `documentRows.test.ts`, `FilesList.tsx`, + `src/widgets/DocumentReview/OsvDocument.tsx` + +**Interfaces:** +- Consumes: `FileWarningOut`, `IssueOut`, `JobBrief` (T1); `IssuesPanel` (T4); `getDocument` (`services/api/documents.ts`). +- Produces: + +```ts +export function toFileWarnings(value: unknown): FileWarningOut[]; // P6-13 +export function warningsLevel(list: readonly { severity: Severity }[]): Severity | null; +export const warningText = (w: FileWarningOut): string => (w.locator ? `${w.message} — ${w.locator}` : w.message); +// documentRows.ts: DocumentRow + fileWarnings: FileWarningOut[]; mappingWarnings: string[]; issueCounts: IssueCountsOut +export function rowBadge(row: DocumentRow): { count: number; level: Severity } | null; + // P6-12: count = fileWarnings.length + issueCounts.error + issueCounts.info; level 'error', если есть error в файле + // или issueCounts.error > 0; count 0 — null +``` + + `IssuesPanel` получает режим `mode: 'statement' | 'document'`: в `document` все записи — пункты без ссылок (P6-19). + +- [ ] **Step 1: Write the failing tests:** + - `toFileWarnings` — `['sheet=ОСВ 60 строки без родителя']` → `[{code: 'LEGACY_WARNING', severity: 'info', message: + 'sheet=ОСВ 60 строки без родителя', locator: null}]`; `['a', {code: 'JSON_FAILED', severity: 'error', message: 'm', + locator: 'file:x_ofr.json'}, 5, {code: 'X'}, {code: 'Y', severity: 'warn', message: 'z'}]` → два элемента + (`LEGACY_WARNING` «a», `JSON_FAILED`); `'строка'` → `[]`; `null` → `[]`; объект без `locator` → `locator null` — + Review Focus 5; + - `warningsLevel` — `[info, error]` → `'error'`; `[]` → `null`; + - `warningText` — `'m — file:x_ofr.json'`; без `locator` — `'m'`; + - `buildDocumentRows` — файл с двумя документами и `job.warnings` из одного `JSON_FAILED` → у обеих строк + `fileWarnings` длины 1; файл `ERROR` без документов — у строки файла тоже; `job null` → `[]`; `mapping_warnings` + переносятся как есть; `issue_counts` документа → `issueCounts` (строка файла без документов — `{error: 0, info: 0}`); + - `rowBadge` — документ без предупреждений файла с `issue_counts {error: 0, info: 2}` → `{count: 2, level: 'info'}`; + `JSON_FAILED` в файле и `{error: 0, info: 1}` → `{count: 2, level: 'error'}`; `{error: 1, info: 0}` без файла → + `level 'error'`; всё по нулям → `null`. +- [ ] **Step 2:** `npx vitest run src/widgets/FilesDrawer/fileWarnings.test.ts src/widgets/FilesDrawer/documentRows.test.ts` — FAIL. +- [ ] **Step 3: Implement**; `FilesList` — в колонке «Статус» под плашкой значок с числом (`rowBadge` → цвет P6-15), + клик (`stopPropagation`, строка-ссылка не срабатывает) открывает `Popover` `FileWarningsPopover`: «Предупреждения + файла» (тег уровня + `warningText`), «Предупреждения обработки» (`mappingWarnings`, `info`), «Предупреждения документа» + — при открытии `getDocument(documentId)` → `issues` (загрузка — `Spin`, ошибка — «Не удалось загрузить», без + `documentId` раздела нет). `OsvDocument` — `IssuesPanel mode='document'` над таблицей при непустом `document.issues`. +- [ ] **Step 4:** `make lint typecheck test build` — PASS. +- [ ] **Step 5: Сверка с макетом** — панель файлов против `docs/mockup/screens/39-org-documents.png` (1600×1000): значок + не ломает высоту строки и ширину колонки «Статус»; расхождения — в ledger. +- [ ] **Step 6: Commit** `feat(files): предупреждения файла структурами и предупреждения документа в панели файлов и у ОСВ (#<issue>)`. + +## Task 11: Сценарий на стенде, сверка с макетом, документация репозитория + +**Files:** +- Modify: `README.md` (раздел про экран проверки формы — что нового, как включить перехват для демо не нужно), + `CLAUDE.md` arm-web («Архитектура»: новые чистые модули `DocumentReview` — `formLayout`, `issuePlaces`, + `statementDraft`, `cellView`, `codeSuggestions`, `lineMenu`, `editRules`, `confirmBlockers`; `FilesDrawer/fileWarnings`) + +**Interfaces:** +- Consumes: всё выше; стенд — `docs/dev/qa-guide.md` §4.1–§4.4 (ядро — из worktree или `main` arm-core с частью А, лучше + А + Б; `make start` в worktree arm-web), формы — §6.9 (Excel Межениновской) и §8.5 (`aep_6.pdf`, при поднятых + сервисах §6.8). + +- [ ] **Step 1: Регрессия на формах 1.x** (реальный стенд, пользователь `ka_go`): прогнать W19–W28b `qa-guide` §8.5 и + форму Excel `1_Форма1_бух_09.xlsx` (§6.9). Ожидание: раскладка и итоги как прежде; оранжевые только ячейки с + `needs_review` (у чистых — нет); переключателя расшифровок нет; «Подтвердить данные» доступна; в шапке — строка + «Организация»; в панели файлов у заданий, сохранённых до v5.19, предупреждения видны как «Информация». Скриншоты против + `41-document-check.png`, `41-02-document-check-edit.png`, `39-org-documents.png`. +- [ ] **Step 2: Перехват v2** (P6-14) — через `browser_run_code_unsafe` Playwright: + +```js +const fixture = /* содержимое src/widgets/DocumentReview/__fixtures__/statement-v2.json, id документа заменён на открытый */; +await page.route('**/api/v1/documents/*/statement', async (route) => { + if (route.request().method() === 'PATCH') { globalThis.lastPatch = route.request().postDataJSON(); } + await route.fulfill({ json: fixture }); +}); +``` + + Сценарии спеки `arm-web` по очереди: «Код-мусор на своём месте» (под 1240, подсвечена, серый «ИЧ,250» с подсказкой); + «Расшифровки скрыты» (не видно, «Показать расшифровки (2)», включённый показывает под 1150 и в начале раздела I); + «Догадка подтверждена проверкой» (1230: не подсвечена, подсказка «Распознано: «42831 1»»); предупреждения у места + (1370 — значок `ROW_SPLIT`, колонка 2 — `COLUMN_WITHOUT_DATE`, организация и дата — значки, `COLUMN_SKIPPED` — в списке); + «Назначить код» (тело `PATCH` — `lines: [{line_id: 'l-u7', line_code: '1250'}]`); «Подтверждение заблокировано» + (подсказка «Разберите строки без кода: 2», клик ведёт к первой); подтверждение ячейки, дата колонки, организация, + подтверждение даты — одно тело. Перехват `POST …/confirm` с 409 `LINES_UNRESOLVED` и `DUPLICATE_FORM`, `PATCH` с 409 + `LINE_CODE_TAKEN` (`detail {line_id: 'l-u7', line_code: '1170'}`) — тексты и места по T7, T9. Перехват + `GET /legal-entities/*/files` с `job.warnings` (`JSON_FAILED` `error`, `locator 'file:x_ofr.json'`) и + `documents[].issue_counts` — сценарий «Потерянный ОФР в карточке», значок у документа без предупреждений файла. +- [ ] **Step 3:** итог сверки — в ledger и в описание PR: что сверено (экраны 41, 41-02, 39), что отличается и почему + (новые элементы, которых нет в макете: поле кода, меню строки, кнопки подтверждения, значки уровней). +- [ ] **Step 4:** README и CLAUDE.md arm-web; `npm run format`; `make lint typecheck test build` — PASS. +- [ ] **Step 5: Commit** `docs(arm-web): экран формы v5.19 — README и карта модулей (#<issue>)`. + +## Завершение + +- [ ] `make lint typecheck test build` — PASS, вывод — в описание PR (с пометкой P6-18: `npm install` пропущен, node_modules — + симлинк). +- [ ] Финальное ревью ветки (opus): код-ревью TS/React с объяснением решений простым языком для владельца (правило + `arm-web/CLAUDE.md`); таблица находок `## Findings` в ledger (`| Маркер | Суть | Статус |`). +- [ ] PR: `tea pulls create --login home --repo RSHB/arm-web --base test --head feat/<issue>-statements-v2`, ссылка на issue и + на arm-ka-docs#116; в описании — «Rulings I made», `P6-n`, O-1…O-6, В-1…В-3, итог сверки с макетом, `deferred`-находки, + строка «выкладывается только вместе с ядром v5.19 (план 3 А + Б); после merge — `make -C docs sync-web`». Merge — по + команде владельца. +- [ ] После merge в `test`: `make -C /Users/andrey.epifancev/projects/awp-ca/docs sync-web SYNC_ARGS=--check` (ожидание — + код 10, отстаёт), затем `make -C …/docs sync-web` — issue и PR в arm-core (ревью кода против бэкенда — за агентом; merge + — по команде владельца). `arm-core/web` напрямую не править. +- [ ] При «закрывай» — чеклист `~/.claude/CLAUDE.md`: `openspec archive statements-v2` в arm-web, SDD workspace, ветка и + worktree `.wt/normalization-web` (симлинк `node_modules` удаляется вместе с worktree, цель симлинка не трогать), follow-up + issue с `deferred` и повторным прогоном на `aep_3` после шага 5 выкладки (В-1). + +--- + +## Покрытие спеки `arm-web` + +| Требование / сценарий | Задачи | +|---|---| +| Раскладка строк формы со статусами: разделы, `ok` по коду, повторы по `row_index`, шаблонные и пятизначные на местах | T2, T3 | +| … `unreadable` / `missing` под ближайшей предыдущей, без неё — в начале первого раздела; подсветка целиком, серый `line_code_text` | T2, T3 | +| … `detail` под переключателем «Показать расшифровки (N)», под родителем с отступом | T2, T3 | +| … справочное имя основным, файловое рядом при отличии; переключателя «как в файле» нет | T2, T3 | +| Сценарии «Код-мусор на своём месте», «Расшифровки скрыты» | T2 (тесты), T11 | +| Подсветка ячеек по `needs_review` с `text` и уверенностью; строки — по `needs_review` строки | T1, T3 | +| Поля шапки — по `needs_review_fields`, подсказка из `header.source` | T5 | +| `issues` и `checks` у строк, ячеек, полей, документа; `column_index` без `column_id` — у документа; `error` ≠ `info` | T4, T5 | +| Значения `detail` не подсвечиваются, подсказка есть; сценарий «Догадка подтверждена проверкой» | T3 | +| Режим «Редактировать»: поле кода с подсказкой, «Это расшифровка», «Назначить код», «Сделать расшифровкой», смена кода, «Подтвердить код» | T7 | +| Подтверждение и снятие подтверждения ячейки и кода; дата колонки (в том числе подтверждение той же даты) | T7, T8 | +| Наименование организации; подтверждение даты отчёта тем же значением | T5, T8 | +| Один `PATCH` (`header`, `values`, `columns`, `lines`); сценарий «Назначить код» | T6, T7, T11 | +| «Подтвердить данные» отключена с подсказкой «Разберите строки без кода: N» и переходом; 409 `LINES_UNRESOLVED`, `DUPLICATE_FORM`, `LINE_CODE_TAKEN` | T7, T9 | +| Сценарий «Подтверждение заблокировано» | T9, T11 | +| Карточка файла: `job.warnings` структурами, `message` и `locator`, `error` ≠ `info`; `DocumentOut.issues`; `mapping_warnings` строками; сценарий «Потерянный ОФР в карточке» | T10, T11 | diff --git a/openspec/changes/unified-statement-normalization/plans/README.md b/openspec/changes/unified-statement-normalization/plans/README.md new file mode 100644 index 0000000..943782c --- /dev/null +++ b/openspec/changes/unified-statement-normalization/plans/README.md @@ -0,0 +1,72 @@ +# Исполнение единой нормализации ББ/ОФР — дорожная карта + +Читать первым при продолжении работы. Источник истины по требованиям — спека этого change (`../specs/`, `../proposal.md`, +`docs/contracts/`, `docs/context/05-domain-rules.md` §7/§9/§11, `docs/model/data-model-v5.19.dbml`); по решениям — `../design.md` +(Р-1…Р-43 владельца, S-1…S-74 писателей спеки). Планы ниже аргументируют от спеки; при расхождении права спека. + +## Планы + +| № | Файл | Репозиторий | Задач | Ветка / worktree | Зависит от | +|---|---|---|---|---|---| +| 0 | `2026-10-18-plan-0-contract-identity.md` | arm-core, tabular-parser, scan-gateway (3 PR) | 5 | `feat/<issue>-contract-identity-window`, `.wt/contract-window/<repo>` | ничего | +| 1 | `2026-10-18-plan-1-arm-statements.md` | arm-statements (новый) | 19 | `feat/<issue>-arm-statements-package`, `.wt/normalization/arm-statements` | push `main` arm-statements | +| 2 | `2026-10-18-plan-2-sync-statements.md` | docs + 3 PR подключения в хостах | 9 | `feat/<issue>-sync-statements`, `.wt/sync-statements/docs`; хосты — `.wt/statements-adopt/<хост>` | PR плана 1 слит | +| 3А | `2026-10-18-plan-3-arm-core.md`, часть А | arm-core | 14 | `feat/<issue>-normalization-core-a`, `.wt/normalization-core-a/arm-core` | план 0 (для A5) | +| 3Б | тот же, часть Б | arm-core | 7 | `feat/<issue>-normalization-core-b`, `.wt/normalization-core-b/arm-core` | PR 3А и PR плана 2 в arm-core слиты | +| 4 | `2026-10-18-plan-4-tabular-parser.md` | tabular-parser (+ golden в fixtures) | 10 | `feat/<issue>-statements-v2`, `.wt/normalization-tp/tabular-parser` | планы 1, 2 | +| 5 | `2026-10-18-plan-5-scan-gateway.md` | scan-gateway (+ golden в fixtures) | 8 | `feat/<issue>-statements-v2`, `.wt/normalization-sg/scan-gateway` | планы 1, 2 | +| 6 | `2026-10-18-plan-6-arm-web.md` | arm-web (PR в `test`), затем `make -C docs sync-web` | 12 | `feat/<issue>-statements-v2`, `.wt/normalization-web/arm-web` | T1–T10 — по фикстуре сразу; T11 — после 3А | + +## Волны + +1. **Волна 1 (параллельно):** план 0 — три PR, слить **до** docs PR #117; план 1 — после push `main` arm-statements; + план 3 часть А; план 6 задачи T0–T10. +2. **Волна 2:** план 2 (после PR плана 1) → затем планы 4, 5 и 3 часть Б (после подключения пакета к хостам). +3. **Финиш волны 2:** план 6 T11 (Playwright на стенде) после 3А, лучше вместе с 3Б. + +Способ исполнения (глобальный CLAUDE.md): на каждую дорожку — фоновый SDD-контроллер **на модели sonnet**; +исполнители — модели, указанные в задачах плана; ревью всей ветки — opus. План ≤ 5 задач (план 0) — executing-plans. +В брифе контроллера: ждать task-notification, не циклы `sleep`; хук не обходить; находки — с маркерами и статусами +(`fixed <commit>` / `deferred` / `rejected`), таблица `## Findings` в ledger. + +## Выкладка (proposal, «сначала ядро») + +0. Слиты PR плана 0 во всех трёх репозиториях. +1. Очистка форм ББ/ОФР на стенде: `api/scripts/purge_statements.py --dry-run` → решение владельца по смешанным файлам + (Р-27) → `--apply`. +2. Ядро А + Б и фронт (план 6 + sync-web) выкладываются одним снимком (`JobBrief.warnings` меняет тип). +3. Слить docs PR #117 (контракт v2.0, DBML v5.19, спека); перед этим повторить проверку плана 0 на его последнем + коммите. +4. Сервисы на 2.0: PR golden в fixtures и PR tabular-parser / scan-gateway сливаются только после выкладки ядра, + golden — непосредственно перед своим сервисом. +5. После выкладки: архивация OpenSpec change'ей в каждом репозитории, follow-up issue по `deferred`. + +## Предусловия и состояние на 18.10.2026 + +- RSHB/arm-statements создан; клон `awp-ca/arm-statements`, `main` локально: начальный README + `adopt-project.sh`. + Push `main` делает владелец (хук guard-main): `git -C ~/projects/awp-ca/arm-statements push -u origin main`. +- Решения владельца по открытым вопросам планов — Р-31…Р-43 в `../design.md`; на прогоне реальных файлов неожиданный + `needs_check` — стоп и вопрос владельцу (Р-31). +- Jira недоступна; что внести после — arm-ka-docs#118 (тестировщику — сразу после слияния, не ждать контур). + +## Решения Р-31…Р-43 и планы + +Решения приняты после написания планов и в тексты планов **не внесены** — контроллер дорожки обязан учесть их в +брифах исполнителей (а при расхождении с планом — следовать решению): + +| Решение | План | Что учесть | +|---|---|---| +| Р-31 | 4, 5 | неожиданный `needs_check` на реальных файлах — стоп и вопрос владельцу, адаптер под файл не подгонять | +| Р-32, Р-33 | 5 | json без таблиц → `JSON_FAILED`; все json ошибочны → перечислить `JSON_FAILED` каждого, код файла — от первого | +| Р-34 | 1, 3, 6 | подтверждение кода — флаг `is_confirmed`, уверенность не меняется (уже внесено в спеку и планы сведением) | +| Р-35 | 3 (A4) | очистка отказывает только при `VERIFIED` / `ARCHIVED` со снимком ЕХ (уже внесено сведением) | +| Р-36 | 0, docs | в окне перехода новых примеров контракта верхнего уровня не добавлять | +| Р-37, Р-38 | 6 | экран принимается по фикстуре 2.0 + регрессия 1.x, прогон aep_3 — follow-up; подтверждённая ячейка — серая галочка | +| Р-39 | 4 | коды предупреждений ОСВ — по источнику `OSV_*` | +| Р-40 | 3 | `CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` — только четырёхзначные коды | +| Р-41 | 3 (A3) | смена `crm_id` ГСК/ГСЗ — в аудит как изменение группы | +| Р-42 | 2 | `sync-statements --check` в `prepare-contour-snapshot.sh` | +| Р-43 | 1 | `arm-statements` — в таблицу workspace (`CLAUDE.md` workspace, `context/00-workspace-layout.md`) | + +Решения исполнителей планов P1-21…P1-31, P3-19…P3-24, P5-20 (появились при сведении API) переносятся в `../design.md`, +раздел «Decisions made without discussion», при исполнении соответствующего плана, вместе с остальными P-n. diff --git a/openspec/changes/unified-statement-normalization/proposal.md b/openspec/changes/unified-statement-normalization/proposal.md new file mode 100644 index 0000000..18b596c --- /dev/null +++ b/openspec/changes/unified-statement-normalization/proposal.md @@ -0,0 +1,151 @@ +## Why + +Формы ББ/ОФР сегодня нормализуются двумя разными конвейерами: scan-gateway (PDF → ИС Сканирование) и tabular-parser +(Excel). У них разные правила раскладки таблицы, отбора строк, разбора сумм, проверок §7 и статуса; ядро не доверяет +ни одному из них и пересчитывает проверки и статус заново по косвенным признакам, дважды дедуплицирует строки и держит +третью и четвёртую копии словарей (§7 и «код → наименование»). Пользователь видит последствия как отдельные «ошибки +распознавания» — AURORA-1406, 1462, 1511, 1517, 1519, 1522, 1523, 1524, 1634: строки молча пропадают, повтор кода +теряет сумму, «чистая» ячейка с потерянной цифрой не подсвечивается, предупреждения сервисов нигде не видны. + +Дизайн `design.md` (финальная версия, решения владельца Р-1…Р-19 от 17.10.2026, три раунда ревью) приводит оба +сервиса к одной цепочке шагов 1–9 с единым выходом каждого шага и общим кодом шагов 2–8; ядро хранит результат как +пришёл и пересчитывает его тем же общим кодом только после правок аналитика. + +Срез: вне плана срезов `09-delivery-plan.md` — переработка Среза 2b (Excel-формы), Среза 3a (OCR-нормализатор) и +Среза 3b (формы в ядре). Issue RSHB/arm-ka-docs#116. + +## What Changes + +- **Общий код нормализации** — новый репозиторий `arm-statements`, пакет `arm_statements/` (Р-1): типы шага 1 + (`FormCandidate`, `Cell`), шаги 2–8 (раскладка, отбор строк, значения, код и наименование, сборка документа, + самопроверки §7, статус и `needs_review`), словари §7 (полный и упрощённый наборы), периодов, заголовков форм, + подписей колонок, упорядоченные перечни кодов форм по редакциям и вариантам, диапазоны кодов. Словаря «код → + наименование» в пакете нет (Р-3). В tabular-parser, scan-gateway и arm-core пакет лежит копией, которую раскладывает + и проверяет скрипт `sync-statements` репозитория docs (по образцу `sync-web.sh`). +- **Строки не выбрасываются молча:** статусы строк `ok` | `unreadable` | `missing` | `detail`, `row_index`, `ref`, + сырой текст кода и ячейки (`text`) доезжают до БД и экрана; повтор кода — законный случай, все строки сохраняются + (Р-4, Р-8). **BREAKING** для потребителей: `line_code` становится nullable. +- **Качество структурно и с привязкой к месту:** `needs_review` у значения, строки и полей шапки; «подтверждение + проверкой» §7 вместо порога уверенности (Р-5, Р-7, Р-11, Р-12, Р-16, Р-17); `document.status` у каждого документа; + предупреждения — структуры `{code, severity, message, line_refs, column_index, field}` вместо строк. +- **Контракт `recognition-result.schema.json` v2.0** (MAJOR, Р-9) и конверт `recognition-result-message.schema.json` + v2.0 (структурные `metrics.warnings`); запрос `recognition-request` остаётся v1.0. Путь ОСВ в tabular-parser + меняется только в части структуры предупреждений и `document.status`. На окне перехода схемы 1.x лежат рядом + (`*.v1.schema.json`, примеры `examples/v1/`); тест идентичности копии у ядра и сервисов сверяет файл копии с файлом + docs своего MAJOR, если он есть, иначе с основным (схемы и примеры; правило — спека `recognition-contract`). +- **Упорядоченные перечни кодов форм** — данные `contracts/statement-code-orders.yaml` (66н / ФСБУ 4, полная / + упрощённая, законные повторы кода — Р-20); в пакет `arm_statements` — байтовой копией. +- **arm-core:** DBML v5.19 и одна миграция (статусы и порядок строк, `needs_review`, `is_confirmed`, `source_text`, + `period_kind`, `form_variant`, таблица `document_issue`, снятие уникальности `(meta_id, line_code)` и — для ББ/ОФР — + `document (stored_file_id, document_type, osv_account_code)`, `recognition_job.statements_version`, + `related_group.crm_id` по arm-core#112); «агрегат формы по коду» вместо «одна строка на код»; приём 1.x и 2.0 на + время перехода; новое условие `RECOGNIZED`; PATCH `…/statement` с `lines`, `columns`, `values[].confirmed`; 409 + `LINES_UNRESOLVED` и `DUPLICATE_FORM` при подтверждении. +- **arm-web:** экран проверки формы — строки со статусами на своих местах, расшифровки под переключателем, подсветка + только по `needs_review`, предупреждения и нарушения у места, назначение кода, «Это расшифровка», подтверждение кода + и ячейки, дата колонки. +- **Документы:** `05-domain-rules.md` — §11 становится общей главой нормализации ББ/ОФР с подразделами по + источникам, §12 — заголовок-редирект; §7 — «посчитанное» соотношение, `abs_terms` и знак, упрощённый набор; §9.3, + §9.5 — статус, маппинг и подтверждение формы по v2.0. `07-open-questions.md` — закрыты OQ-36, OQ-141 (Р-8) и + вопросы, снятые решениями дизайна; новые вопросы — склейка половинок формы (Р-15) и открытое в дизайне. + `frontend-api-guide.md` — новые поля формы и PATCH (после реализации). + +Читают и пишут контракт: `arm-core` (читает результат и конверт, публикует запрос), `tabular-parser` и `scan-gateway` +(читают запрос, пишут результат и конверт). Дочерние change'и (заводятся при планировании, по одному на репозиторий): +`arm-statements` (новый), RSHB/arm-core, RSHB/arm-tabular-parser, RSHB/arm-scan-gateway, RSHB/arm-web; связанная +задача — RSHB/arm-core#112 (`related_group.crm_id` входит в ту же миграцию, Р-10). + +### Порядок выкладки — «сначала ядро» (обязателен) + +0. arm-core, tabular-parser и scan-gateway: тест идентичности копии контракта по новому правилу — схема копии + сверяется с `<имя>.v<M>.schema.json` своего MAJOR, если такой файл есть в docs, иначе с основным; пример — с + `examples/v<M>/<имя>.json`, если он есть, иначе с `examples/<имя>.json`; файлы другого MAJOR не сверяются. Правило + проходит и до слияния шага 2 (файлов `.v1` ещё нет), и после; без него `make check` всех трёх краснеет между шагами 2 + и 3 (ядро) или 2 и 5 (сервисы). +1. `arm-statements` — пакет с тестами; `sync-statements` в docs; копия пакета подключается к хостам (линтеры, + `testpaths`, `statements-check`) — код сервисов её ещё не вызывает. +2. docs: контракт v2.0, DBML v5.19, правила (этот change). +3. arm-core: **сначала очистка форм ББ/ОФР на контуре** (Р-24: владелец или платформенная команда по его заявке, + после резервной копии БД — скрипт ядра `scripts/purge_statements.py`: `--dry-run` с отчётом, затем `--apply`; удаляются + документы `BALANCE` / `INCOME`, их `financial_statement_*`, комментарии и привязки к версиям «в работе», а файлы, где + кроме форм нет ОСВ, — целиком с заданиями; смешанные файлы «ОСВ + формы» — по решению владельца по отчёту; при + формах в верифицированных версиях скрипт отказывает; ОСВ, консолидации, справочники не трогаются — спека `arm-core`, + «Очистка форм перед выкладкой»), затем миграция v5.19 (только колонки, индексы и значения по умолчанию; §7 для + старых форм не считается), маппер 2.0 рядом с маппером 1.x, приём ack и конвертов 1.x и 2.0, PATCH, экранные поля + API. Формы пересоздаются повторной загрузкой файлов. До этого шага ни один сервис не шлёт 2.0: первый же + `line_code = null` дал бы файлу `ERROR`. +4. arm-web: экран по новым полям (поля 1.x-документов заполнены по умолчанию — экран работает и на них). +5. tabular-parser и scan-gateway переходят на 2.0 в любом порядке относительно друг друга. +6. После перехода обоих сервисов маппер 1.x удаляется из ядра отдельным PR (не в этом change). + +Копии схем в сервисах (`api/contracts/`, тест идентичности в `make check`) после слияния контракта v2.0 в docs +сверяются с `*.v1.schema.json` и `examples/v1/` (правило шага 0; у запроса файла `.v1` нет — с основным); каждый сервис +обновляет копию до v2.0 в своём PR шага 5, ядро держит копии обоих MAJOR с PR шага 3. Схемы `*.v1.schema.json` и +`examples/v1/` удаляются вместе с маппером 1.x (шаг 6): сначала PR ядра убирает свои копии `.v1`, затем docs. + +**Чего ждать на реальных сканах.** Значения строк `detail` в `needs_review` и в статус не входят (Р-23), законные +повторы кода и мягкие строки ОФР документ не держат (Р-20, Р-21). Поэтому ББ и ОФР РБ («Бухгалтерская отчетность 1 +кв.25») и Счастливочной («БО 1 кв 2025») могут стать `done`: по прогону ревью все их неуверенные значения строк с кодом +покрыты сошедшимися соотношениями, неуверенные коды подтверждаются перечнем `fsbu4`, а держали их только ячейки +расшифровок. aep_3 и aep_6 остаются `needs_check` (строки `unreadable` / `missing` с суммами, несошедшиеся +соотношения). Фактическая доля `done` фиксируется в PR scan-gateway. + +## Non-goals + +- Разбор ОСВ (`classify.py`, `to_money` ОСВ, правила §1) не меняется; путь ОСВ в tabular-parser меняется только в + структуре предупреждений и `document.status`. +- Переключатель «как в файле» (порядок строк по `row_index`) на экране — не в этой работе (Р-14). +- Склейка двух половинок формы из одного файла в ядре не предоставляется — файл перезагружают; вопрос в + `07-open-questions.md` (Р-15). Склейка страниц из разных файлов (OQ-140) — тоже нет. +- Восстановление строк по наименованию (OQ-149) и сопоставление формы без колонки кодов по именам строк (OQ-139) — нет. +- Запрос `recognition-request` остаётся v1.0; subject'ы NATS, `Nats-Msg-Id`, ack и повторы не меняются по механике. +- Межформенная проверка `1370 − 1370(нач. года) = 2400` остаётся в ядре и мягкой; формулы §7 по редакциям (OQ-146) и + знаки 2410/2420 (OQ-12) не пересматриваются. +- Замена пакета-копии на зависимость из Nexus платформы — не сейчас (платформа Python-пакеты не собирает, Р-1). +- Приведение единиц измерения сверх числового формата ячейки (OQ-138) — нет. + +## Capabilities + +### New Capabilities +- `statement-normalization`: общая цепочка нормализации ББ/ОФР (шаги 1–8) — пакет `arm_statements`, его типы, + правила и словари, `sync-statements`. +- `tabular-recognition-service`: адаптер Excel шага 1 в tabular-parser и что остаётся в сервисе (чтение книги, листы + ОСВ, сборка конверта v2.0). +- `scan-gateway`: адаптер json ИС шага 1 в scan-gateway и что остаётся в сервисе (горячие папки, склейка набора json, + `raw/`, сборка конверта v2.0). +- `arm-core`: хранение результата v2.0 (DBML v5.19), агрегат по коду, `document_issue`, условие `RECOGNIZED`, PATCH + формы, порядок 409 подтверждения, приём 1.x и 2.0. +- `arm-web`: экран проверки формы ББ/ОФР по новым полям. + +### Modified Capabilities +- `recognition-contract`: файл результата и конверт — v2.0; правила перехода (ядро принимает 1.x и 2.0, «сначала + ядро»); статус и структурные предупреждения у всех документов, включая ОСВ; тест идентичности копии — по MAJOR. +- `quality-checks`: `make check` охватывает копию `arm_statements` (тесты, `mypy`), `make check-full` — `statements-check` + (`sync-statements --check`). + +## Impact + +- Новый репозиторий `arm-statements`; скрипт `sync-statements` и цель Makefile в docs (вызывается из `make check-full` + потребителей, не из `make check`; коды 0 / 10 / 20 / 2). +- docs: `contracts/statement-code-orders.yaml`, схемы `*.v1.schema.json` и `examples/v1/` на окне перехода; + `scripts/validate_contracts.py` проверяет оба MAJOR и перечни кодов, `make venv` ставит PyYAML. +- tabular-parser: `parser/statements/*` (кроме разбора книги и адаптера шага 1) уходит в общий код; словари + `statement_checks.yaml`, `statement_periods.yaml`, `statement_headers.yaml` переезжают в пакет; путь ОСВ — структура + предупреждений и `document.status`; манифест `api/tests/statements_manifest.yaml` обновляется: `failed_relations` + страниц 2 баланса (`unagrande-co/2024-FY/Б.Б. Лист 2.xls`, `unagrande-group/2024-FY/ББ. Лист 2 (2).xls`, + `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls`) получают `1600 = 1700` («посчитанное» соотношение, пустая цель), + статусы `done` не меняются. +- scan-gateway: `normalizer/ocr/text|dates|amounts` становятся общими разборами, `layout|rows|header|document` — шагами + 2–6 общего кода; `api/rules/statement_lines.yaml` удаляется, `statement_labels.yaml` и `ocr_lookalikes.yaml` уходят в + словари пакета. +- arm-core: очистка форм ББ/ОФР перед выкладкой (`scripts/purge_statements.py`, Р-24); миграция v5.19 + (`financial_statement_*`, `document`, `document_issue`, `recognition_job`, `related_group`; только колонки, индексы и + значения по умолчанию, §7 по старым формам не считается — на неочищенной базе их нарушения до первой правки не + показываются); `JobBrief.warnings` — структуры, `DocumentOut.issues` (в том числе ОСВ); `result_mapping`, `statement_mapping`, + `statements`, `documents`, `auto_confirm`, схемы API формы; признак строки правки — `row_index IS NULL`; + `api/rules/statement_checks.yaml` удаляется (копия словаря §7), `statement_lines.yaml` остаётся только словарём имён + (несколько имён на код, загрузчик повтор кода не запрещает); PATCH шапки принимает `organization_name`. +- arm-web: `widgets/DocumentReview` (экран [41]), типы API. +- Решение v5.13 «`needs_review` считается, не хранится» пересматривается (design, Decisions № 17). +- Риск окна выкладки: пока сервис шлёт 1.x, документы его конвейера получают статус по прежнему правилу D-6 и поля + v2.0 по умолчанию — экран и подтверждение работают, новых сигналов качества нет. diff --git a/openspec/changes/unified-statement-normalization/review-2026-10-17-blind-2.md b/openspec/changes/unified-statement-normalization/review-2026-10-17-blind-2.md new file mode 100644 index 0000000..ac67688 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/review-2026-10-17-blind-2.md @@ -0,0 +1,220 @@ +# Финальное ревью design.md вслепую — третий раунд (17.10.2026, Opus) + +Ревьюер: отдельный агент (модель Opus), без права правки и без доступа к отчётам предыдущих раундов; получил +design.md после коммита e6400bf, правила, контракт, DBML, фикстуры и код; прогонял текущий парсер на Excel-фикстурах. +Маркеры находок — H-n. + +Сверка перед передачей владельцу (сессия): подтверждены по коду и правилам H-1 (= W-1; манифест парсера, строки 11, +24, 32), H-2 (`document.py:203-206` — шлюз требует уверенность 100 у ИНН и организации и распознанную дату), +H-8 (`result_mapping.py` R-1: первый документ при повторе типа — `NEEDS_CHECK`), H-10 (§11.1 п. 4, строка 1006: +«документ `needs_check`: разбивку подтверждает аналитик», решение владельца 08.10.2026), H-16 (= W-12; +`subline_parents`: «те же три цифры»). Пересечения с отчётом Sonnet: H-1 = W-1, H-5 = W-9, H-9 = W-10, H-11б = W-2, +H-11в = W-11, H-13б = W-5, H-16 = W-12, H-18 = W-14. + +Отчёт агента ниже без сокращений. + +--- + +# Ревью дизайна unified-statement-normalization (свежий взгляд) + +Документ: `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization/docs/openspec/changes/unified-statement-normalization/design.md`. Номера строк дизайна ниже приблизительные (±2). + +## Critical + +**H-1 (critical). Лист только с активом снова получает `done` и автоподтверждается.** +- Где: шаг 7, правило «соотношение посчитано, только если хотя бы один член непуст» (F-2, ~стр. 554); «Подтверждение проверкой». +- Дыра. Членом считается только правая часть: так прямо сказано по аналогии с AURORA-1527 и так же используется в правиле G-2. Правило `1600 = 1700` при отсутствующей строке 1700 больше не считается. +- Сценарий. `unagrande-co/2024-FY/Б.Б Лист 1.xls` — в манифесте парсера `needs_check` «только актив: 1600 = 1700 не сходится» (`tabular-parser/api/tests/statements_manifest.yaml:8-11`). Я прогнал парсер: + - 1100, 1200, 1600 сходятся (1200 — в пределах ±1); + - 1700, 1300, 1400 и 1500 в файле нет; + - по новым правилам `1600 = 1700` не считается, жёстких нарушений нет; + - организация, ИНН и период есть, коды Excel структурно не проверяются → `done` → в ядре `RECOGNIZED` → автоподтверждение половины баланса. + - То же у `unagrande-group/*/ББ. Лист 1*.xls`. + - Сегодня правило проверяется при присутствующей цели, отсутствующие члены = 0 (`tabular-parser/api/src/parser/statements/checks.py:121-127`; ядро `arm-core/api/src/domain/statement_checks.py:200-205`). +- Как закрыть: считать цель членом при проверке «посчитано» (пропускать только случай «все пусты, включая цель»). Добавить в обязательные тесты общего кода строки манифеста со статусом `needs_check` — они должны остаться `needs_check`. + +**H-2 (critical). Уверенность шапки выпала из критерия `done`: неверно прочитанная дата отчёта даёт неверный период и автоподтверждение.** +- Где: шаг 8 п. 1 (критерий `done`) и п. 2 (уровни предупреждений); «Подтверждение проверкой» — фраза «Не покрыто проверками и остаётся на уверенности: поля шапки» (~стр. 616); шаг 9 (список изменений контракта); шаг 6 («`confidence` → `*_confidence`»). +- Дыра. Механизма для полей шапки нет: + - `needs_review` есть только у значений и строк; + - в критерии `done` уверенности шапки нет; + - в уровнях есть только «ИНН не найден или неуверен при найденной организации» (`info`) и «дата не разобрана» (`info`); случая «дата разобрана, но уверенность < 100» нет вовсе; + - в контракте v2.0 не добавлены `report_date_confidence`, `units_confidence`, `*_text`: шаг 6 обещает их, шаг 9 — нет. + - Сегодня шлюз требует 100 у ИНН и организации и «распознана» у даты (`scan-gateway/api/src/normalizer/ocr/document.py:202-208`, `ocr/__init__.py:79-84,103`). Ядро требует, чтобы конверт был `done`. +- Сценарий. В шапке ИС ИНН и организация с уверенностью 100, а «30 сентября 2025» прочитано как «30 сентября 2024» с уверенностью 71. Таблица чистая. + - Эффективная дата = дата отчёта (она приоритетнее колонок), период 3 кв. 2024. + - Относительные колонки тоже считаются от неё. + - Итог: `done` → автоподтверждение и привязка к версиям 3 кв. 2024. + - Автозамена (§9.5 п. 3) вытесняет чужой файл того же ключа, в том числе с подтверждённым документом. + - В фикстурах дата шапки часто мусор: aep_3 «2|0|2|4|»@75, у aep_6 и «БО 1 кв» в поле даты — название организации с уверенностью 100. +- Как закрыть: + - ввести признак «требует проверки» для полей шапки (как `needs_review`); + - дату отчёта с уверенностью < 100 подтверждать структурой: совпала с самой поздней абсолютной датой колонок — подтверждена, иначе `error`; + - то же для подписей колонок OCR с уверенностью < 100; + - добавить поля уверенности шапки в список изменений v2.0. + +**H-3 (critical). В сегменте без шапки роль `title` поглощает строки с суммами — данные теряются молча, `missing` недостижим.** +- Где: шаг 2, определение ролей (`title` — «а без заголовка — выше первой строки с кодом», ~стр. 410) и п. 4; шаг 3, определение `missing` («между заголовком сегмента и первой строкой с кодом … потерян в самом начале таблицы», ~стр. 450). +- Дыра. Для сегмента без шапки всё выше первой строки с кодом — `title`, и в данные оно не попадает. Поэтому строка с потерянным кодом в начале такой таблицы исчезает. Это противоречит принципу 2 и определению `missing`. +- Сценарий 1, aep_3 (в ИС нет строки шапки): + - table 0, row 0: «Нематериальные активы», код `'НИ'@0`, сумма `'30127'@60`. Первая строка с кодом — row 1 (`1120`), поэтому row 0 — `title`, НМА пропадает; + - table 1, row 0: «Выручка», код пуст, `'211(1 |'`. Это выше `2120`, тоже `title`. + - Сегодня обе строки хотя бы считаются в «строк без кода с суммами пропущено» (golden `fixtures/ocr/expected_normalize/aep_3.json`). Теперь даже счётчика нет, Р-13 не блокирует подтверждение, ядро достраивает пустую шаблонную 1110. +- Сценарий 2, Excel. Лист-продолжение без шапки, первая строка — расшифровка без кода с суммой. В НПЗ такие строки есть: «Депозитные счета» на Листе 3, «Авансы по инвестиционной деятельности» на Листе 1. Если разрыв страницы придётся на них, строка становится `title` и пропадает. +- Как закрыть: `title` — только выше строки заголовка (и титульный блок первого сегмента Excel). В сегменте без шапки строки выше первого кода, где в колонках значений есть содержимое: `missing`, а при непустой ячейке кода — `unreadable`. + +**H-4 (critical). Структурная проверка кода не ловит подмену на соседний или незапечатанный код — автоподтверждение с неверной разноской.** +- Где: «Код и наименование строки» (LIS по порядку бланка, «Равные соседние коды допустимы (Р-4)»), Р-5, Р-12. Дизайн сам приводит мотив: «перепутанные соседи 1220 и 1230 дают ту же сумму раздела». +- Дыра. LIS ловит только нарушение порядка. Есть два пути мимо: + - **(а) дубль.** OCR прочитал 1230 как 1220@50. Последовательность 1210, 1220, 1220, 1240 допустима: равные соседи разрешены Р-4. Сумма раздела в `1200 = …` та же → покрыто → `done`. Итог: 1220 = НДС + ДЗ, 1230 пуст. Такой дубль неотличим от законного: в «Бухгалтерская отчетность 1 кв.25.json» row 28 — настоящая вторая строка `1220@50` «НДС, начисленный по отгрузке». + - **(б) подмена на незапечатанный код того же раздела.** 66н позволяет не печатать пустые строки; в том же файле 1120 нет: 1105, 1110, 1130. Чтение 1110 как 1120@50 даёт порядок 1105, 1120, 1130 — согласован. Сумма 1100 та же → `done` с НМА в строке «Результаты исследований». +- Как закрыть: решение владельца, варианты: + - для кода с уверенностью < 100 сделать условием `RECOGNIZED` в ядре совпадение распознанного наименования со справочным по коду (открытый вопрос ядра перестаёт быть открытым — это знание ядра, как квартал); + - либо дубль с уверенностью < 100 и подмена в «дыру» бланка → `needs_review`. + - В любом случае признать риск в тексте: сейчас текст обещает защиту, которой нет. + +## Important + +**H-5 (important). Диапазоны кодов противоречат сами себе и молча расширены.** +- Где: шаг 2 п. 1 («1xxx — ББ, 2xxx — ОФР, 3xxx–6xxx …; `type_hint` неизвестен — объединение»), пункт про tabular-parser («диапазон формы → объединение»), шаг 3 п. 2 (G-21: «"44 30\nI |" aep_6 не множится»), шаг 6 п. 1 (тип по большинству кодов). +- Дыра: + - Если `type_hint` известен и берётся только его диапазон, то в OCR-таблице с `label` «ББ» и кодами 2xxx колонка кодов не найдётся. Сверка «тип по кодам» в шаге 6 станет недостижимой, а сегодняшнее поведение шлюза #30 (единодушные коды) потеряется. + - Если всегда объединение, то «4430» — код 4xxx, и пример G-21 неверен: строка разлепится. Строка aep_6 row 90 `'4 400\nI #4У'` даст строку `ok` с кодом 4400 в балансе. + - Расширение до 1000–6999 по сравнению с сегодняшними 1100–1799 / 2100–2999 / 5000–5999 (`tabular-parser/api/rules/statement_headers.yaml:86-88`) делает кодами годы 2000–2099 («2025» титульных блоков) и 4-значные суммы на 1–6. +- Как закрыть: явно задать «кодовые диапазоны» (объединение узких диапазонов, как сейчас, плюс 3xxx/4xxx/6xxx по точным границам форм) и «диапазон формы» (для наследования и слабых продолжений). Исправить пример G-21. + +**H-6 (important). Неясно, на каком уровне выдаётся `error` «строка заголовка не найдена»: если на сегмент — НПЗ теряет `done`.** +- Где: шаг 2 п. 4 и п. 2. +- Сценарий. Книга НПЗ сегодня `done` (оба документа). У листов «Бухгалтерский баланс. Лист 2/3/5» и «Отчет о финансовых результата_2» шапки нет: я выгрузил ячейки. Если предупреждение п. 4 выдаётся на каждый сегмент — документ `needs_check`, регрессия. +- Дополнительно. Колонки сегмента без шапки «по числам» сопоставляются с первым сегментом по порядку. Если на листе-продолжении средняя колонка пустая или в прочерках, найдутся две колонки, и значения 2023 окажутся в колонке 2024. Документ `needs_check`, но в БД данные лягут неверно. Сегодня `join_blocker` такой лист не присоединяет (`tabular-parser/api/src/parser/statements/merge.py:39-40`). +- Как закрыть: `error` — только если заголовка нет ни у одного сегмента документа. Колонки сегмента без шапки сопоставлять по положению относительно столбца кода или по колонкам первого сегмента; при несовпадении значения сегмента не раскладывать. + +**H-7 (important). Сборка листов до раскладки снимает защиту по числу колонок.** +- Где: шаг 1 п. 6 («по маркеру или по позиции листа»; «проверка числа колонок переезжает в шаг 2 п. 2»). +- Сценарий. После формы идёт лист без маркера и заголовка с 3+ кодами того же диапазона, например расшифровка или форма другого периода, с другим числом колонок. + - Сегодня это отдельное «одинокое продолжение». + - По дизайну он вливается сегментом. Его коды дублируют коды формы и по Р-4 суммируются. §7 падает, но разделить данные аналитик может только пометкой каждой строки как `detail`. +- Как закрыть: оставить несовпадение числа колонок условием отказа в склейке (продолжение становится отдельным кандидатом с `error`). + +**H-8 (important). Р-15 автоподтверждает первую форму, а сегодня первая — `NEEDS_CHECK`; аналитик может оказаться в тупике.** +- Где: Р-15, «Что уходит из ядра» (`statement_status`, «Подтверждение документа»). +- Дыра: + - Сегодня при второй форме того же типа первая получает `NEEDS_CHECK` (`arm-core/api/src/services/result_mapping.py:352,364`, R-1/OQ-77). + - Дизайн запрещает автоподтверждение только второй. Первая может автоподтвердиться, а подтверждение второй — 409. + - Если неверна первая (двойная печать, половинка), выхода нет: `CONFIRMED` только для чтения, повторная загрузка того же файла отсекается дедупом по sha256, удаление часто запрещено (§9.5 п. 5). + - Неоднозначно и «у ЮЛ» в 409: только тот же файл или любые файлы ЮЛ (после правки даты отчёта)? +- Как закрыть: при двух и более формах одного типа и периода в файле не автоподтверждать ни одну. Уточнить область 409. + +**H-9 (important). Противоречие: OCR-таблица без колонки кодов с неопознанным `label` пропадает с `info`.** +- Где: шаг 6 п. 1 («`type_hint` нет → не документ, `info`») и список `info` в шаге 8 п. 2 («не документ без `type_hint` и кодов») против списка `error` («таблица или сегмент с содержимым без колонки кодов»). +- Сценарий. Плохой скан: `label` ОФР зашумлён (не сопоставился), колонка кодов не найдена. Таблица — `info`, а соседний ББ `done` → конверт `done` → ББ автоподтверждён, потеря ОФР не видна. Сегодня шлюз выдаёт «нет шапки и столбца кода» → `needs_check` (`scan-gateway/api/src/normalizer/ocr/__init__.py:96-98`). +- Как закрыть: для источника `ocr` каждая таблица ИС — кандидат, её потеря — `error`. Для Excel «прочее» — `info`. + +**H-10 (important). Разлепление склеенных строк молча меняет решение владельца по OQ-156.** +- Где: шаг 3 п. 2; таблица уровней шага 8 п. 2. +- Дыра. В §11.1 п. 4 по решению владельца 08.10.2026: «документ `needs_check`: разбивку подтверждает аналитик». В таблице уровней разлепления нет. В «Бухгалтерская отчетность 1 кв.25» строка `1500\n1700` по суммам сходится, и документ может стать `done` и автоподтвердиться. Решение не вынесено ни в Р-n, ни в «Decisions made without discussion». +- Как закрыть: явно указать уровень и записать в решения. + +**H-11 (important). Пересчёт ядра после PATCH: устаревшие `error` сервиса и подтверждения, которые нигде не хранятся.** +- Где: принцип 3; «Подтверждение проверкой» («предупреждения `error` документа сохраняются и после правки»); PATCH; DBML v5.19. +- Дыры: + - **(а)** Из `error` сервиса правкой снимается только «колонка без даты». После PATCH `report_date` остаются «период не определён» и «дата не разобрана», после PATCH `inn` — «ни организации, ни ИНН». Статус после правки навсегда `NEEDS_CHECK`, а экран показывает уже исправленные причины. + - **(б)** `values[].confirmed` негде хранить: в списке изменений v5.19 у `financial_statement_value` нет признака подтверждения. На следующем пересчёте `needs_review` вычисляется заново из уверенности и §7, и подтверждение пропадает (G-12). + - **(в)** Нет способа подтвердить верный код с низкой уверенностью без смены. Переход `ok → ok` описан только «с другим кодом», поэтому `needs_review` строки снять нельзя. +- Как закрыть: таблица «какое `error` сервиса снимается какой правкой»; колонка `is_confirmed` (или `confirmed_by/at`) у значения и у строки в v5.19; переход «подтвердить код». + +**H-12 (important). Шаблонные строки и новые поля строки.** +- Где: PATCH `lines` («шаблонная строка с таким кодом заменяется настоящей»), DBML v5.19. +- Сценарий. Код строки НМА потерян (`unreadable`). + 1. Аналитик вводит суммы в достроенную шаблонную 1110. Ядро создаёт её в БД (`line_code_original_value = NULL`; адресация по `line_code` — `arm-core/api/src/services/statements.py:219-245`). + 2. Потом аналитик назначает код 1110 строке `unreadable`. + 3. Возможны два исхода: обе строки `ok` и суммируются по Р-4 (двойной счёт), либо «замена» стирает ручные суммы (аудит?). +- Также не определены: + - `row_index`, `source_ref`, `line_status` для строк, созданных правкой; + - заполнение этих полей для существующих строк при миграции; + - уникальность `source_ref` при NULL. + - `repositories/statements.py:74` ключует вставку по `line_code`. +- Как закрыть: правило слияния шаблонной строки (перенос правок или запрет с 409), значения полей для строк из правки, заполнение при миграции. + +**H-13 (important). Упрощённые формы: регрессия ОФР и недостижимое определение.** +- Где: шаг 6 п. 1 (`form_variant`), шаг 7, решения 14–15. +- Дыры: + - **(а)** Правило G-2 «цель отсутствует при непустых членах — жёсткое нарушение» делает упрощённый ОФР (2110, 2120, 2330, 2340, 2350, 2410, 2400 без 2100/2200/2300) вечным `needs_check`. Структурный признак упрощённой формы задан только для баланса. Решение 14 утверждает «как и сегодня», но сегодня цель отсутствует — правило пропускается, и упрощённый ОФР может быть `done`. + - **(б)** 0710096 — по моим сведениям КНД ФНС, а не ОКУД печатной формы; на бланке ББ/ОФР стоят 0710001/0710002. Проверить. Кроме того, 0710096 подпадает под шаблон DISCLOSURE словаря ОКУД (`statement_headers.yaml:26`) → `type_hint = DISCLOSURE`, а для DISCLOSURE по подсказке шаги 2–7 не выполняются. До определения варианта дело не дойдёт. + - Это частный случай противоречия «окончательно — по кодам строк в шаге 6, у обоих» и «DISCLOSURE по `type_hint` — шаги 2–7 не выполняются». +- Как закрыть: структурный признак для ОФР (есть 2400/2110, нет 2100/2200/2300); снять 0710096 или поправить его разбор; оговорить исключение для DISCLOSURE. + +**H-14 (important). Объём контракта v2.0 и перехода не охватывает ОСВ и конверт.** +- Где: шаг 9, шаг 8 п. 3. +- Дыры: + - `metrics.warnings` становится структурой. В том же результате tabular-parser отдаёт `osvDocument` и строковые предупреждения ОСВ; переделка пути ОСВ не описана. + - «Конверт `done` ⇔ все документы `done`» — у ОСВ нет `document.status`. Не определено, как агрегируется смешанная книга ОСВ + формы. + - При приёме 1.x у документа нет `document.status`, значит нужен откат к старому D-6 (`job_done ∧ checks ∧ values_ok …`). Сказано только про `needs_review`. + - ack тоже несёт `schema_version` — ядро должно принимать ack 1.x/2.0 в переходный период. + - `to_money` используется и ОСВ (`tabular-parser/api/src/parser/classify.py:183`). Замена его общим `parse_amount` (шаг 4 п. 1) без ограничения областью форм меняет разбор ОСВ: двойники, «лучшая догадка». +- Как закрыть: описать в спеке контракта ОСВ-часть и агрегацию; запасной статус для 1.x; ограничить замену `to_money` формами. + +**H-15 (important). xlsb + «тыс. руб.» + дробные значения = вероятный масштаб ×1000, а уровень — `info`.** +- Где: шаг 4 п. 4; шаг 8 п. 2 (`info`: «дробные при тыс. руб.», «xlsb без числовых форматов»). +- Сценарий. Выгрузка 1С в xlsb хранит рубли с копейками, а в тысячи их переводит формат, который для xlsb не читается. Значения в 1000 раз больше, §7 сходится при любом масштабе → `done` → автоподтверждение в КФО. Это унаследовано (сегодня статус тоже не меняется), но дизайн закрепляет это явно. +- Как закрыть: сочетание «xlsb без форматов ∧ доля дробных ≥ порога при тыс.» → `error`. + +## Minor + +**H-16 (minor). Правило родителя пятизначной подстроки описано неверно.** +- Дизайн: «ближайшая строка выше с теми же четырьмя первыми цифрами, иначе код из первых четырёх цифр — правило `subline_parents`». +- Код: первые **три** цифры (M-9): `arm-core/api/src/domain/statement_checks.py:141-162`, `tabular-parser/.../checks.py:91-96`; §12 п. 7 «12310 под 1230». +- По тексту дизайна 12310–12314 НПЗ (Лист 2) теряют родителя. Для OCR-кодов с уверенностью < 100 это ещё и неподтверждённая структура → `needs_check`; ломаются мягкая проверка и показ. + +**H-17 (minor). Неточности базовой линии.** +- Таблица шага 2, строка 1: шлюз ищет столбец кодов по справочнику кодов (`infer_layout`, `scan-gateway/api/src/normalizer/ocr/layout.py:82`), а `int()` — это классификация строк. +- G-2 «сегодня пропуск»: верно только для отсутствующей цели. Цель с `null` при непустых членах уже сегодня жёсткое нарушение (null = 0 при присутствующей строке). + +**H-18 (minor). `parse_amount` не доопределён.** +- Не указано, как правило «точка — разделитель тысяч» (sg#37) работает для `excel`. Сегодня Excel «1.500» = 1,5, при общем правиле станет 1500. +- Не указана судьба текста OCR с более чем двумя знаками после запятой («0,125», решение №30: `amount = null`). +- Не определена многострочная сумма в строке с однострочным кодом: склейка «7А 794\n8 У 201» в одну «догадку». Правило «многострочное → `null`» сформулировано только в контексте разлепления. + +**H-19 (minor). Р-2 и шаг 8 расходятся для пустого текста с уверенностью 0.** aep_3 table 1 row 15: «пустая — без подсветки» или «непрочитанная — `needs_review`»? + +**H-20 (minor). Строки без кода, где в суммах только мусор, выбрасываются как «не строка».** aep_3 row 8: код `[190 :`, значения `'Ё'@0`, `' : ।'@0`, `'" :'@33`. Ячейки с непустым текстом и уверенностью < 100 — признак потерянных сумм; по принципу 2 их стоит считать содержимым. + +**H-21 (minor). `missing` в начале нового сегмента или листа.** +- Расшифровка, перенесённая через разрыв страницы, получает `missing` вместо `detail` и блокирует подтверждение (Р-13). +- Предложение: родитель `detail` продолжается из предыдущего сегмента документа; `missing` — только в первом сегменте. + +**H-22 (minor). Мусор в ячейке кода, в которой нет ни одной цифры.** aep_6 ОФР row 4: «в том числе: Крестьянская, 175», код `'}'@0`, сумма `'по\n(0 215)'`. Строка становится `unreadable` с суммой и блокирует подтверждение, хотя это расшифровка. Стоит решить, считать ли такой код пустым (`detail`/`missing`). + +**H-23 (minor). Р-11 и вертикальные линии таблицы.** «|» вплотную к цифрам в суммах — частый след границы таблицы (aep_3 `'| : 122477'`). Замена на 1 даёт лишнюю цифру: §7 подсветит, но ложных подсветок станет больше. Для сумм крайние «|» убирать, а не заменять. + +**H-24 (minor). Мелкие противоречия и пробелы.** +- DISCLOSURE по кодам при `type_hint` BALANCE — одновременно «`error`» (тип противоречит) и «DISCLOSURE всегда `done`». +- Снятие уникальности `document` (Р-10) не должно снимать её у ОСВ по счёту и у DISCLOSURE/OTHER — «один на тип на файл», §9.5 п. 2. Нужен частичный уникальный индекс. +- Место `LINES_UNRESOLVED` в порядке 409 (§9.5 п. 4) не задано. + +**H-25 (minor). Сцепка через статус конверта (G-7) шире, чем нужно.** +- Сценарий: в «Бухгалтерская отчетность 1 кв.25» ОФР — `needs_check` из-за 2400@71, а 2400 есть только в мягких соотношениях. Из-за этого не автоподтверждается и чистый ББ того же файла. +- При статусе на документ достаточно условия «`document.status = done` ∧ нет `error` уровня файла». + +**H-26 (minor). Р-1: сопровождение копии.** +- Расхождение `statements_version` с копией ядра → `error` при пересчёте. Но предписанный порядок выкладки («сначала ядро») гарантирует расхождение на всё окно выкладки: правленые документы будут массово `NEEDS_CHECK`. Лучше `info` или сравнение хэша словарей. +- Копия «со своими тестами и конфигом линтеров» попадает под ruff и pytest хоста: конфликты правил, `testpaths`, `conftest`. +- Срочное исправление требует трёх PR в разное время — нужен описанный порядок. + +## Проверено и признано согласованным + +- Р-4 (сумма по коду) на реальных данных верна. «Бухгалтерская отчетность 1 кв.25»: 1220 = 19 062 + 9 273 сходится с 1200 = 1 677 325; 1520 = 771 605 + 89 221 сходится с 1500 = 917 775. Сегодняшнее «оставлена первая» здесь даёт ложное нарушение. +- `detail` по Р-8 на НПЗ («Авансы…», «Депозитные счета») и в «1 кв.25» («в том числе…») сохраняет `done`, если строки стоят под строкой с кодом в том же сегменте. +- Защита от титульного блока «Коды» (V-1/G-5) на НПЗ (кол. 23 против кол. 13) и Межениновской (кол. 16 против кол. 13) работает: под титульной подписью 0 кодов. Роль `title` выше строки заголовка корректно убирает «Дата 30 | 9 | 2025». +- Конвенция знака (F-1) на ОФР «1 кв.25» и aep_3: «(7744536}» → минус, «{38378)» → подсветка. Ведёт себя, как описано. +- Р-7 при посчитанных соотношениях: подтверждение `is_recognized = false` требует компенсирующей ошибки — согласовано с Р-5 и V-13. +- Межформенная проверка, `_line_amount` и `statement_amounts` перечислены в F-15. Привязка к версии (`consolidation_documents._key` + «самый поздний `confirmed_at`») согласуется с 409 по Р-15. + +## Что смотрел + +- Дизайн целиком; `05-domain-rules.md` §7, §9.2–9.5, §11, §11.1, §12; контракт (`$comment`, `statementDocument`, `metrics`, `service`, `status`); DBML: `document`, `financial_statement_*`, `ex_snapshot_item`; `03-architecture.md` (контракт и копии словарей). +- **scan-gateway:** `normalizer/ocr/__init__.py`, `document.py` (`build_document`, `_resolve_type`, `_columns`, `_choose_edition`, `_value`, `_line`, `_statement_header`), `layout.py`, `rows.py` (`split_merged_row`, `parse_table_rows`, `_count_skipped`), `amounts.py`, `text.py`, `header.py`, `normalizer/rules.py`, `rules/ocr_lookalikes.yaml`, `statement_labels.yaml`. +- **tabular-parser:** `statements/columns.py`, `lines.py`, `merge.py`, `statement.py`, `checks.py`, `book.py`, `numbers.py` (`to_money`, `_parse_text`, `quantize_money`), `rules/statement_headers.yaml`, `statement_checks.yaml`, `tests/statements_manifest.yaml`. +- **arm-core:** `domain/statement_mapping.py` (`build_statement`, `statement_period`, `statement_status`, `values_ok`), `domain/statement_checks.py` (`run_checks`, `subline_parents`, `cross_form_check`), `services/statements.py` (`form_confirmable`, `_apply_values`, `_resolve_cells`, `_apply_header`), `services/documents.py` (`_check_statement`), `services/result_mapping.py` (OQ-77/R-1), `services/consolidation_documents.py` (`_key`, `bind_confirmed`), `repositories/statements.py`, `web/src/widgets/DocumentReview/statementGrid.ts`. +- **Фикстуры:** построчно `fixtures/ocr/aep_3.json`, `aep_6.json`, «БО 1 кв 2025.json», «Бухгалтерская отчетность 1 кв.25.json»; golden-эталоны `expected_normalize/*`. Прогон текущего парсера на `unagrande-co/2024-FY/Б.Б Лист 1.xls`, `Б.Б. Лист 2.xls`, НПЗ, Межениновская Форма1/Форма2; выгрузка ячеек листов НПЗ и Межениновской. Скрипты лежат в `/private/tmp/claude-501/-Users-andrey-epifancev-projects-awp-ca/12de9a9a-5665-41d0-beea-0651270d88ec/scratchpad/runp.py` и `/private/tmp/claude-501/-Users-andrey-epifancev-projects-awp-ca/12de9a9a-5665-41d0-beea-0651270d88ec/scratchpad/dump.py`. +- Файлы не правил. diff --git a/openspec/changes/unified-statement-normalization/review-2026-10-17-blind.md b/openspec/changes/unified-statement-normalization/review-2026-10-17-blind.md new file mode 100644 index 0000000..fcef2af --- /dev/null +++ b/openspec/changes/unified-statement-normalization/review-2026-10-17-blind.md @@ -0,0 +1,249 @@ +# Независимое ревью design.md вслепую — второй раунд (17.10.2026) + +Ревьюер: отдельный агент (модель Fable), без права правки и без доступа к первым двум отчётам; получил обновлённый +design.md (после коммита 63f134b), правила, контракт, DBML, фикстуры и код. Маркеры находок — G-n. + +Сверка перед передачей владельцу (сессия): подтверждены по коду G-1 (`statement_lines.py:20` — только четырёхзначные +коды; 1105 в словаре ядра нет), G-2 (соотношение с отсутствующей целью пропускается: `statement_checks.py:205`, +`checks.py:122`), G-3 (`string_value` только при `amount = null`, `document.py:149-150`), G-4 (`dates.py` шлюза +разбирает квартал и полугодие, словарь периодов парсера — нет), G-7 (`job_done` входит в `statement_status`, +`statement_mapping.py:222-232`). Пересечения с отчётом о закрытии: G-1 = V-2, G-3 = V-3, G-5 = V-1, G-6 = V-6, G-8 ≈ V-4, +G-11 = V-5 + V-10, G-30 ≈ V-12. + +Отчёт агента ниже без сокращений. + +--- + +# Независимое ревью дизайна `unified-statement-normalization/design.md` (17.10.2026) + +Маркеры находок — `G-n`. Серьёзность: `critical` — неверный результат или потеря данных; `important` — пробел, который придётся решать до спеки; `minor` — неточность. Все сценарии проверены на реальных фикстурах (`fixtures/ocr/*.json`, `fixtures/statements-xlsx/**`) и коде трёх репозиториев; ссылки на код — `файл:строка`. + +--- + +## CRITICAL + +### G-1 (critical) — Подтверждение кода структурой (Р-5, «Код и наименование строки») делает `done` недостижимым для всех фикстур OCR и ломает НПЗ (Excel) + +**Место:** шаг 8 «Код и наименование строки», шаг 5 п. 2, Р-5. + +**Дыра.** Правило: «код подтверждён, если он есть в перечне и его положение относительно соседних подтверждённых кодов совпадает с порядком бланка …; иначе `needs_review` у строки, документ — `needs_check`». Источник «упорядоченного перечня кодов» не назван; единственный существующий кандидат — справочник ядра `arm-core/api/rules/statement_lines.yaml` (из шаблонов банка). В нём: +- только четырёхзначные коды (`arm-core/api/src/domain/statement_lines.py:20` — `_CODE = re.compile(r"^\d{4}$")`, загрузчик отвергает остальные); +- нет `1105` (Гудвил) ни в одной редакции, нет `1530` и `2460` в `fsbu4`, нет `2420` в `66n`, нет `2930`. + +**Сценарии по данным:** +- Все четыре скана с таблицами (`aep_3`, `aep_6`, `БО 1 кв 2025`, `Бухгалтерская отчетность 1 кв.25`) содержат строку `1105` (1С печатает её всегда); формы 2025 года содержат `1530` и `2460` → по правилу эти строки `needs_review` → документ `needs_check`. Цель Р-7/AURORA-1524 («чистый скан может получить `done` и автоподтвердиться») недостижима ни на одной фикстуре. +- НПЗ (`npz/2025-Q3/…xlsx`, манифест: `BALANCE done`, `INCOME done`) — колонка кодов содержит пятизначные подстроки `11101, 11102, 11103, 11501…11509` (проверено `openpyxl`). Их нет ни в каком перечне → каждая такая строка `needs_review` → регрессия `done → needs_check`, не признанная в документе. Шаг 8 п. 1 говорит «Excel выполняет условия уверенности автоматически», но правило Р-5 — структурное, не по уверенности, и Excel под него попадает. +- Определение циклично: «относительно соседних **подтверждённых** кодов» — базы рекурсии нет (уверенность «не важна», значит ничем не заякорить первый подтверждённый код). +- Упрощённые формы (КНД 0710096: порядок `1150, 1170, 1210, 1250, 1230, 1600`) нарушают порядок полного бланка (`1250` раньше `1230`) → их коды никогда не подтверждаются (см. также G-17). + +**Как закрыть:** (1) назвать источник перечня и зафиксировать, что он = объединение кодов шаблонов банка **и** кодов печатных форм 1С по редакции (`1105`, `1530`, `2420/2460`, `2930`, упрощённый бланк отдельным перечнем); (2) пятизначные коды подтверждать через четырёхзначный префикс-родителя (как `subline_parents`, `arm-core/api/src/domain/statement_checks.py:141`), не искать их в перечне; (3) снять рекурсию: «положение относительно ближайших соседей, **которые есть в перечне**»; (4) явно: при `source = excel` (уверенность 100) структурная проверка не ставит `needs_review`, а даёт `info`-предупреждение ядра «код не из редакции». + +### G-2 (critical) — Р-8 (`detail`) + не оговорённое правило «цель присутствует» + ячейки с уверенностью 100 ⇒ `done` и автоподтверждение документа без итоговых строк + +**Место:** шаг 3 (`detail`, п. 4), шаг 7 (правила счёта), шаг 8 п. 1, «Что уходит из ядра» (`RECOGNIZED`). + +**Дыра.** Шаг 7 не говорит, проверяется ли соотношение, у которого отсутствует цель. Сегодня оба производителя и ядро соотношение с отсутствующей целью **пропускают** (`tabular-parser/api/src/parser/statements/checks.py:122`, `arm-core/api/src/domain/statement_checks.py:206`). F-2 («посчитано, если хотя бы один член непуст») про цель молчит. Если правило пропуска сохраняется, строка итога, потерявшая код, уходит в скрытый `detail`, её соотношение не считается, остальные сходятся, ячейки с уверенностью 100 не требуют покрытия → `done` → `RECOGNIZED` → автоподтверждение; агрегат по коду итога пуст, в КФО/ЕХ итога нет. + +**Сценарий по данным (aep_3):** +- table 0, row 17: `БАЛАНС`, код пуст, суммы `5553383 / 5399730 / 5920582` → по Р-8 это `detail` с родителем `1200`, скрыта по умолчанию; `1600` в документе нет. +- table 0, row 29: `Итого по разделу III` (1300) → `detail` под `1370`. +- table 1, row 2: `Валовая прибыль (убыток)` (2100; код уехал в ячейку значения «2100 I 150416») → `detail` под `2120`. +В aep_3 документ всё равно `needs_check` (есть `unreadable`), но тот же скан с чистыми остальными ячейками проходит в `done`: `1600 = 1100 + 1200`, `1600 = 1700`, `1300 = Σ`, `2100 = 2110 − 2120` — пропущены (цель отсутствует), остальное сошлось. + +**Как закрыть:** (1) явно зафиксировать в шаге 7: жёсткое соотношение, у которого цель отсутствует или `null`, а хотя бы один член непуст, — **нарушение**, не пропуск (это одна строка в правилах и закрывает сценарий); (2) дополнительно: `detail` недопустим под строкой-итогом (`xx00`) — «БАЛАНС» под `1200` становится `missing`; (3) см. G-10 про ручное подтверждение. + +### G-3 (critical) — Сырой текст значения не хранится; пересчёт `needs_review` ядром «тем же кодом» (знак по конвенции колонки, F-1) невозможен, подсказка «сырой текст остаётся в подсказке ячейки» без источника + +**Место:** шаг 4 п. 2–3, шаг 8 «Подтверждение проверкой» (знак), «Что уходит из ядра» (DBML v5.19, пересчёт после PATCH), Decisions № 10. + +**Дыра.** Конвенция знака сравнивает «форму записи знака у ячейки (скобки, минус, без знака)» — это сырой текст. В контракте `string_value` заполняется **только** при `amount = null` (`scan-gateway/api/src/normalizer/ocr/document.py:149`, решение № 16; DBML `financial_statement_value.string_value` — «как распознано, если не число»). Шаг 4 п. 3 одновременно требует `string_value = сырой текст` для «догадки» с непустым `amount` — третья семантика. В списке DBML v5.19 нового поля с сырым текстом нет. Значит после PATCH ядро не может повторить F-1: либо вернёт ложные подсветки (AURORA-1524), либо пропустит правило — результат сервиса и ядра разойдутся. Данные (текст `(7744536}`) теряются. + +**Сценарий:** aep_3 ОФР, `2120` = «(7744536}» → `amount = −7744536`, `is_recognized = false`, `string_value = null`. Аналитик правит `2210` в той же колонке → пересчёт → для `2120` конвенцию знака не из чего считать. + +**Как закрыть:** добавить в контракт v2.0 и в `financial_statement_value` (v5.19) поле `source_text` (сырой текст ячейки всегда), оставить `string_value` в прежнем смысле; либо хранить результат конвенции как отдельный флаг (`sign_confirmed`) и не пересчитывать его в ядре. Зафиксировать одну семантику `string_value`. + +### G-4 (critical, по регрессии данных) — «Один словарь периодов» молча заменяет разборщик дат шлюза более слабым + +**Место:** шаг 1 п. 3 («дата → `date` — общий код по `text`»), шаг 2 п. 3 («один словарь периодов `statement_periods.yaml`»), принцип 5. + +**Дыра.** Сегодня два разных разборщика: `scan-gateway/api/src/normalizer/ocr/dates.py` (диапазон «с … по …», «I квартал 2026», «1 полугодие», «9 мес.», числовые «30/09/25», двузначный год, склейка 8 цифр «30092025», хвост «0:00:00», «2|0|2|4|» через разделитель клеток) и `tabular-parser/api/rules/statement_periods.yaml` (`on_date`, `numeric_date` только с точками и четырёхзначным годом, `for_months`, `for_range`, `for_year`). Квартал, полугодие, диапазон, слэши, двузначный год, склеенные цифры в yaml **отсутствуют**. Документ не говорит, какой разборщик становится общим и что теряется. + +**Сценарий:** aep_3 `date.form_name = "2|0|2|4|"` → сегодня `2024-12-31` (golden `report_date: "2024-12-31"`); по yaml → `null` → у aep_3 нет дат колонок (нет шапки) → «период не определён» (`error`) и редакция не выбрана. Также любой скан с подписью «за I квартал 2025 г.» или «с 01.01.2025 по 31.03.2025» теряет дату колонки → `error` «колонка без даты» (Р-6) → `needs_check`. + +**Как закрыть:** общий разборщик = yaml-словарь, **дополненный** шаблонами `dates.py` (квартал/полугодие/диапазон/слэши/двузначный год/склейка цифр) как данными; перечислить в спеке тестовые строки `test_dates.py::test_parses_real_forms` как обязательные для словаря. + +--- + +## IMPORTANT + +### G-5 (important) — Защита F-5 «под подписью есть код» пропускает титульный блок 1С с ОКОПФ по ОК 028-2012 + +**Место:** шаг 2 п. 1 (а), Decisions № 3. + +**Дыра.** Коды ОКОПФ современного классификатора пятизначные с префиксом в диапазоне ББ: `12300` (ООО → 1230), `12267` (АО → 1226), `12247` (ПАО → 1224), `12165`… 1С печатает их в столбце титульного блока «Коды». НПЗ: `12300` стоит в колонке 23 — той же, что подпись «Коды» (row 26, col 23; проверено `openpyxl`), выше неё; у Межениновской подпись «КОДЫ» стоит над блоком (row 4, col 18), ниже — ОКУД, ОКПО, ИНН, ОКВЭД, `65`, `384`. Форма с подписью над блоком и ОКОПФ `12300` под ней (стандартная раскладка 66н) проходит защиту «хотя бы один код из диапазона» → (а) выбирает титульный столбец, (б) не вызывается → документ без строк. + +**Как закрыть:** в (а) требовать под подписью ≥ `min_codes` кодов (3), а при нескольких подписях брать столбец с наибольшим числом кодов; пятизначный код считать кодом только при наличии в том же столбце его четырёхзначного префикса. + +### G-6 (important) — «ИНН не распознан» = `error` противоречит критерию `done` «(организация или ИНН)» и регрессирует ОФР НПЗ + +**Место:** шаг 8 п. 1 и п. 2; Decisions № 1. + +**Дыра.** П. 1: `done` ⇐ «(организация **или** ИНН)». П. 2: «ИНН не распознан» — `error` → `needs_check`. Условие «или» мёртвое. Сегодня это предупреждение выдаёт только шлюз (уровень файла); у парсера `inn = null` статуса не меняет (`statement.py:239` — `organization_name or inn`). + +**Сценарий:** `npz/2025-Q3` ОФР — на листе нет метки «ИНН» (§12 п. 6, манифест `inn_optional_for: [INCOME]`, статус `done`) → под единым правилом `needs_check`. Регрессия не признана. + +**Как закрыть:** «ИНН не распознан» → `info`, если организация найдена (сверку контрагента ядро делает по имени); `error` — только когда нет ни того ни другого; согласовать п. 1 и п. 2. + +### G-7 (important) — Решение № 28 (ошибочный json набора понижает статус) сохраняется только в конверте, который ядро больше не читает + +**Место:** шаг 8 п. 2–3; «Что уходит из ядра» (`RECOGNIZED ⇔ document.status = done ∧ …; остальные признаки D-6 не пересчитываются`). + +**Дыра.** Сегодня `job_done` входит в `statement_status` (`arm-core/api/src/domain/statement_mapping.py:231`). Дизайн убирает его. Ошибка уровня файла (`aep_3_ofr.json: INVALID_DOCUMENT`) делает конверт `needs_check`, но `document.status` у BALANCE может быть `done` → `RECOGNIZED` → автоподтверждение, хотя ОФР потерян — ровно то, чего № 28 избегал. + +**Как закрыть:** либо оставить `job_done` (статус конверта) в условии `RECOGNIZED`, либо класть предупреждения файла уровня `error` в `document_issue` каждого документа файла. + +### G-8 (important) — Потеряна проверка диапазона кодов для слабого продолжения (N-1): «Справка» с кодом баланса приклеивается к ОФР + +**Место:** шаг 1 п. 6 («`join_blocker` остаётся только по типу»), шаг 2 п. 1 («`relaxed` … не нужен»). + +**Дыра.** Сегодня лист с 1–2 кодами без маркера присоединяется только если коды из диапазона открытой формы (`tabular-parser/api/src/parser/statements/statement.py:143-159` `_weak_blocker`, тесты `test_statement.py:394` «N-1»). В дизайне продолжение клеится «по позиции листа» и сверяется лишь тип из маркера (которого у такого листа нет). Лист «Справка» с одной строкой `1370` после ОФР попадает в сетку ОФР; большинство кодов — `2xxx`, тип не меняется, ошибки нет; строка `1370` становится `ok`-строкой ОФР. Поймает только предупреждение ядра «код не из редакции формы» к строке — оно не `error`, документ может быть `done`. + +**Как закрыть:** для сегмента без шапки и без маркера с числом кодов < `min_codes` сохранить проверку «коды из диапазона `type_hint`», иначе — отдельный кандидат + `error`. + +### G-9 (important) — `ref` не уникален: разлепленные строки и многолистовые формы → `line_ref` предупреждений и `document_issue.line_id` неразрешимы + +**Место:** шаг 1 (`Cell.ref`), шаг 3 (`RawLine.ref`), шаг 8 п. 5, DBML `source_ref`. + +**Сценарии:** `БО 1 кв 2025` row 73 «1500\n1700» → две строки с одним OCR `row_index` → одинаковый `ref`. НПЗ ББ — 5 листов в одной сетке; `ref` «адрес A1» без имени листа: `B12` есть на каждом листе. + +**Как закрыть:** грамматика `ref`: OCR — `row:<row_index>[#<k>]` (k — часть разлепления), Excel — `<лист>!<A1>`; требование уникальности в документе. + +### G-10 (important) — Ручное подтверждение документа со строками `missing`/`unreadable`, несущими суммы: их значения не входят в агрегат, КФО и ЕХ-снимок + +**Место:** шаг 3 п. 4, «Что уходит из ядра» (агрегат по коду), §9.5 подтверждение. + +**Дыра.** Агрегат берёт только строки `ok`. Условия подтверждения (`arm-core/api/src/services/documents.py:207-237`, `statements.py:70-81`) — период и жёсткие нарушения (акцептуемые с комментарием). Документ с `missing` «Выручка» (aep_3 ОФР row 0, `2110`, 8096738) подтверждается после акцепта → в КФО выручки нет, в `ex_snapshot_item.payload` её нет. То же для `detail`, принятых по Р-8 (цена признана), но для `missing`/`unreadable` решения нет. + +**Как закрыть:** `form_confirmable`/`_check_statement` → 409 `LINES_UNRESOLVED`, пока есть `missing`/`unreadable` строки с непустым `amount`; аналитик назначает код или объявляет `detail` (F-14). Зафиксировать, что `detail` в ЕХ-снимок не попадают. + +### G-11 (important) — `needs_review` строки есть в контракте и на экране, но его нет в DBML v5.19; `statements_version` негде хранить + +**Место:** шаг 8 (единый выход: «`needs_review` … у каждой строки»), «Что уходит из ядра» (DBML v5.19; «версия … пишется в `recognition_job`»). + +**Дыра.** Список полей v5.19 содержит `financial_statement_value.needs_review`, но не `financial_statement_line.needs_review` (результат Р-5). Принцип 3 запрещает считать при чтении. У `recognition_job` нового столбца нет. + +**Как закрыть:** добавить `financial_statement_line.needs_review boolean`; `statements_version` — столбец `recognition_job.statements_version` или ключ в `metrics`. + +### G-12 (important) — Снять `needs_review` без изменения значения нельзя; пустые ячейки с уверенностью 0 (Р-2) неправимы + +**Место:** шаг 4 п. 5, шаг 8 «Подтверждение проверкой» («правка снимает `needs_review`»), «Что уходит из ядра» (PATCH). + +**Дыра.** PATCH с тем же значением — не изменение (`arm-core/api/src/services/statements.py:205` `if value.amount == cell.amount: continue`; для несуществующей ячейки `amount = null` — пропуск, строка 197). Ячейка с уверенностью 71 и верным значением, не покрытая соотношением (25xx/29xx, колонки с мягкими правилами), или пустая ячейка с уверенностью 0 (aep_3 table 1 — две такие) остаётся `needs_review` навсегда; документ не выходит из `NEEDS_CHECK` иначе как через подтверждение целиком. + +**Как закрыть:** в PATCH `values[].confirmed: true` (или явная запись того же значения считается подтверждением) → `needs_review = false`, `is_modified` не ставится, аудит `RECOGNIZED_DATA_EDIT`. + +### G-13 (important) — Колонка без даты (Р-6): нет API поставить дату; `period_date` остаётся `null`, межформенная проверка и гудвилл «на отчётную дату» молча не считаются + +**Место:** шаг 2 п. 6, Р-6 («аналитик подтверждает после сверки с источником»), «Что уходит из ядра» (правка даты отчёта). + +**Дыра.** `HeaderPatch` правит только `inn/report_date/units` (`arm-core/api/src/schemas/statements.py:318`). «Ядру колонка без даты не мешает» верно лишь для `statement_period`; `_line_amount(period_date=…)` (`repositories/statements.py:306-327`), `pair_amounts`, сравнение версий и ЕХ используют `financial_statement_column.period_date`. + +**Как закрыть:** PATCH `columns: [{column_id, period_date}]` с аудитом и пересчётом `is_current`/относительных колонок; `error` «колонка без даты» снимается после правки. + +### G-14 (important) — Р-1: границы пакета `arm_statements` не определены (типы выхода, golden-тесты, словари) + +**Место:** Р-1, шаг 9, принцип 1. + +**Дыра.** (а) Шаги 2–8 отдают `StatementDocument` «как в контракте», но pydantic-модели контракта живут отдельной копией в каждом репозитории (`scan-gateway/api/src/contract/result.py`, `tabular-parser/api/src/contract/result.py`, `arm-core/api/src/contract/result.py:150-196`); во время перехода у ядра две модели (1.x и 2.0). Если пакет импортирует `src.contract`, он зависит от хоста и копия в ядре работает «по совпадению». (б) Р-1: «golden-файлы в fixtures» — соседний репозиторий; F-17 и `03-architecture.md` прямо запрещают тесты, читающие соседние репозитории (урок arm-core #33). Сегодня golden шлюза так и устроен: `scan-gateway/api/tests/golden_update.py:18` `REAL_FIXTURES = ../../fixtures/ocr`. Если тесты пакета копируются в три репозитория, они упадут в пайплайне платформы. (в) Свежесть копии проверяет только ручной `sync-statements --check`; расхождение всплывает у пользователя как `error` при PATCH (F-17). + +**Как закрыть:** пакет владеет своими dataclass-ами выхода; хост конвертирует в свою модель контракта; golden пакета — внутри пакета (анонимизированные json), фикстурные тесты остаются только в scan-gateway/tabular-parser; `sync-statements --check` в `make check-full` (он запускается перед PR локально, не в пайплайне). + +### G-15 (important) — Порядок строк на экране не определён: «как в файле, по `row_index`» против действующей раскладки «как на бумаге» + +**Место:** «Что уходит из ядра» (экран: «строки как в файле, по `row_index`»; «строки missing/unreadable … на своём месте по row_index»). + +**Дыра.** Сегодня фронт строит раскладку разделами и сортирует по коду (`arm-core/web/src/widgets/DocumentReview/formLayout.ts:1-4`, просьба владельца 09.10.2026), `load_form` сортирует по `line_code` (`repositories/statements.py:280`). Шаблонные строки `row_index` не имеют; `detail` должны стоять под родителем по `parent_row_index`, не по коду; при двух раскладках одновременно `unreadable` строка «на своём месте» и шаблонная `1600` оказываются в разных местах. + +**Как закрыть:** одно правило: строки источника — по `row_index`; шаблонные — вставляются по порядку бланка между ближайшими соседями из источника; `detail` — под родителем; разделы — заголовками по первому коду сотни. Или явно оставить раскладку по коду, а `row_index` — только для `detail`/`unreadable`. + +### G-16 (important) — Текстовые ячейки Excel в OCR-`parse_amount`: смена семантики «4,200», потеря знака «−» (U+2212), тонкие пробелы + +**Место:** шаг 4 п. 1 («Excel-ячейки „число текстом“ идут тем же путём»), п. 3. + +**Дыра.** Сегодня парсер: `"4,200"` → `4.20` (`numbers.py:101` запятая → точка), `"−1 234"` → `−1234` (`_MINUS_SIGN`), пробелы `[\s ]`. OCR `parse_amount` (`amounts.py:60`): `"4,200"` → `4200` (тысячи); `"−"` не в `_ALLOWED_RE` → выбрасывается → `+1234`, `is_recognized = false`; U+202F не убирается. Для членов `abs_terms` знак подхватит конвенция колонки, для прочих (напр. `1370` отрицательная нераспределённая прибыль текстом) — только если соотношение не сойдётся. + +**Как закрыть:** в общем `parse_amount` добавить `−` в знаки и класс пробелов парсера; правило «запятая — тысячи» для `source = excel` применять только при ≥ 2 группах (`1,234,567`) или не применять. + +### G-17 (important) — Упрощённые формы (КНД 0710096) не рассмотрены: жёсткие соотношения и перечень кодов им не подходят + +**Место:** шаг 7, «Код и наименование строки», словарь `statement_checks.yaml`. + +**Дыра.** В упрощённом балансе нет `1100/1200/1300`-подытогов части строк: `1600 = 1100 + 1200` с присутствующей целью и отсутствующими членами (с правилом G-2 — нарушение; сегодня — тоже нарушение в парсере и ядре, т. е. каждая упрощённая форма всегда `needs_check` с акцептом). Для OCR ни одна ячейка не покрывается (жёсткие соотношения не сходятся), все `< 100` подсвечены; порядок кодов (`1250` перед `1230`) не совпадает с полным бланком → строки не подтверждаются (G-1). + +**Как закрыть:** отдельный набор соотношений и перечень кодов «упрощённая» в словарях, выбираемый по ОКУД `0710096` или по отсутствию `1100/1200` при наличии `1600`. + +### G-18 (important) — Р-11 на ИНН портит ИНН aep_3; порядок «двойники → убрать пробелы» в разборе кода теряет «| 140» + +**Место:** принцип 5 («ИНН и дата шапки (шаг 1)»), шаг 3 п. 1 («двойники, пробелы и переносы убираются, затем 4–5 цифр»). + +**Сценарии:** aep_3 `inn.form_name = "3|1 21 2| о! О' 0 0; 3: 51"` → сегодня `digits_only` → `3121200351` (10 цифр, golden). С двойниками `|`→`1` вплотную к цифре («3|1», «2|») → 12 цифр → другой ИНН той же «валидной» длины → сверка с ЮЛ даёт `MISMATCH` вместо совпадения. Эвристика «`|` — разделитель клеток» (`text.py:22,29`) не срабатывает из-за букв в строке. Коды: aep_3 row 3 «| 140» (это 1140) — в заданном порядке `|` не вплотную к цифре → остаётся → после удаления пробелов «|140» → `unreadable`; в обратном порядке → `1140` → `ok`. + +**Как закрыть:** ИНН — только цифры без двойников (или двойники-буквы `О/З/б/S/Ч`, но не `|/I/l`); для кодов зафиксировать порядок «убрать пробелы и переносы → двойники → 4–5 цифр» и ограничить `|`→`1` случаем, когда результат попадает в диапазон кодов. + +--- + +## MINOR + +### G-19 (minor) — Шаг 6 п. 1: `DISCLOSURE` «шаги 2–7 не выполняются», но тип по большинству кодов определяется в шаге 6 после шагов 2–5; коды 3xxx/4xxx вне диапазонов шага 2 п. 1 +Таблица ОИК/ОДДС с нечитаемым `label` → `type_hint = null` → коды `3xxx/4xxx` не считаются кодами → «колонка кодов не найдена» → «не документ, `info`», а не «прочие → DISCLOSURE». Зафиксировать: `DISCLOSURE` по `type_hint` — шаги 2–7 пропускаются; по кодам — шаги 2–5 выполняются, строки отбрасываются. + +### G-20 (minor) — Уровни: «в шапке не найдена организация» (парсер `NO_ORGANIZATION_WARNING`) без уровня; «формат файла не совпал с `file_type`» становится `error` (сегодня — предупреждение, `done` возможен) — регрессия не признана. + +### G-21 (minor) — F-25 множит мусор: aep_6 row 6 «44 30\nI |» и row 90 «4 400\nI #4У» (значения пустые) → по две `unreadable` строки из одной. Сегодня разлепление требует, чтобы все части были цифрами (`rows.py:98`). Требовать, чтобы хотя бы одна часть разобралась как код. + +### G-22 (minor) — Правило кода «текст без пробелов и переносов, 4–5 цифр» оставляет `1220 ,` (aep_3 row 11, НДС, суммы 157/4547/5746) в `unreadable`; сегодня строка тоже теряется (`classify_row` делает `int()` по сырому тексту, `rows.py:63`), так что не регрессия, но обрезка краевой пунктуации (как `clean_value`) даёт `ok` — это и есть цель 1462. + +### G-23 (minor) — `HeaderPatch.report_date: null` очищает дату; относительные колонки (Р-6/F-10) сохранят `period_end`, посчитанный от старой даты, и `statement_period` возьмёт его как «абсолютную» — определить обнуление. + +### G-24 (minor) — Р-10: две формы одного типа и периода в одном файле автоподтверждаются одним `now`; привязка берёт «самый поздний `confirmed_at`» с разрешением ничьей по `uuid` (`consolidation_documents.py:115-118`) — к версии привязывается случайный дубль. Решить: вторую форму того же типа и периода не автоподтверждать (`NEEDS_CHECK`) или выбирать по порядковому номеру формы. + +### G-25 (minor) — `document_issue.line_id?` одиночный, а нарушение «подсвечивает все строки кода» (Р-4: `1220` ×2 в `БО`); нужен список или строка на каждую `line_id`. Не сказано: `_clear_file` чистит `document_issue`; `core`-источник заменяется при каждом пересчёте (иначе «версия копии расходится» останется после обновления ядра). + +### G-26 (minor) — Принцип 3 («ядро при загрузке ничего не пересчитывает») против «проверки по словарю строк ядро делает само при загрузке» — уточнить формулировку. + +### G-27 (minor) — Факт: «у ИС поле `date` в трёх из четырёх фикстур не дата» — с текущим разборщиком не дата у двух (aep_6, БО); у aep_3 «2|0|2|4|» разбирается, у «Бухгалтерская отчетность» — «за январь - март 2025 г.». Три из четырёх — только с yaml-разборщиком (G-4). + +### G-28 (minor) — Шаг 2 п. 1 перечисляет подпись «Код строки», но `code_caption: ^код(ы)?$` парсера её не матчит (OCR `is_code_header` — `startswith("код")` матчит); расширить словарь. + +### G-29 (minor) — Столбец наименования и исключение «Пояснения» (`_name_column`, `note_cols`, `_line_name` с поиском левее кодов) в целевых шагах 2/5 не описаны; у OCR — своё правило («наименование», иначе слева от кода). Задать одно. + +### G-30 (minor) — PATCH назначения кода: в `before/after` аудита должны попадать `line_status` и `parent_row_index` (операция «объявить `detail`»); после назначения `needs_review` строки → `false` (аналог `is_modified`) — не сказано. + +--- + +## Что проверил и признал консистентным + +- **Р-4 (сумма по коду)** на реальных данных: «Бухгалтерская отчетность 1 кв.25» — `1220` ×2 (19 062 + 9 273), `1520` ×2 (771 605 + 89 221); `1200 = Σ` и `1500 = Σ` сходятся только с суммой; сегодняшний golden отбрасывает вторые строки и соотношения в ядре не сходятся. Решение подтверждается данными. +- **Р-2 (пустые ячейки):** в четырёх json ни одной пустой ячейки с `0 < confidence < 100`; `confidence = 0` при пустом тексте — только aep_3 table 1 (2 шт.). Основание Р-2 верно. +- **Р-8 (`detail`):** правило «ближайшая строка с кодом выше» на всех «в том числе» aep_6/БО/Бухгалтерская даёт правильных родителей (`1210`, `1230`, `1250`, `1410`, `1520`, `2110`, `2120`, `2340`, `2350`). +- **F-25 / OQ-156:** «1500\n1700» с тремя парными суммами в БО и Бухгалтерской разлепляется по правилу. +- **Сегменты:** повторные шапки «Код» на страницах 2 (aep_6 row 72, БО row 42, Бухгалтерская row 50) укладываются в правило шага 2 п. 2. +- **Словарь §7:** `arm-core/api/rules/statement_checks.yaml` и `tabular-parser/api/rules/statement_checks.yaml` побайтно совпадают (без комментариев) — тезис «две копии» верен, расхождения сейчас нет; `abs_terms` в дизайне (`2120, 2210, 2220, 2330, 2350, 2410`) = словарю; допуск ±1 и округление 0,001 % описаны как в коде (`checks.py:127-140`, `statement_checks.py:214-222`). +- **Эффективная дата** = `statement_period` ядра (`statement_mapping.py:73-80`), редакция по границе 2024-12-31 (`edition_for`, `rules.py:88-93` шлюза) — согласовано. +- **Р-9 / F-7:** `StatementLine.line_code: str` в ядре (`contract/result.py:160`) — `null` действительно уронит валидацию; порядок «сначала ядро» обоснован. +- **Р-10 и привязка:** ключ привязки `(ЮЛ, тип, счёт, квартал, нарастающий)` (`consolidation_documents.py:104-111`) не использует уникальность `document`, снятие индекса привязке не мешает (кроме G-24). +- **Грамматика `locator`** и golden-эталоны `file:<json>;table:<i>` — не меняются (F-23) — верно. +- **Таблица уровней** шага 8 п. 2 и Decisions № 1 согласованы между собой (кроме G-6, G-20). +- Decisions № 2, 3, 5, 6, 7, 11, 12, 13 согласованы с соответствующими шагами. + +## Что смотрел + +**Документы:** `design.md` (целиком), `05-domain-rules.md` §7, §11, §11.1, §12, `03-architecture.md` (контракт, копии словарей, цикл маппинга), `recognition-result.schema.json` v1.2 (`$comment`, `statementDocument`, `failedRelation`, `money`, `metrics`, `service`), DBML v5.18 (`document`, `financial_statement_meta/column/line/value`, `recognition_job`, `audit_event`, enum `audit_action`, `document_status`). + +**Фикстуры (скриптом `python3 -I`):** `fixtures/ocr/aep_3.json`, `aep_3_ofr.json`, `aep_5.json`, `aep_6.json`, `aep_8_form4.json`, `БО 1 кв 2025.json`, `Бухгалтерская отчетность 1 кв.25.json`; `expected_normalize/{README.md, aep_3, aep_6, БО 1 кв 2025}.json`; `statements-xlsx`: `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx`, `1_Форма2_бух_09.xlsx`, `npz/2025-Q3/…xlsx` (openpyxl), `tabular-parser/api/tests/statements_manifest.yaml`. + +**scan-gateway:** `api/src/normalizer/ocr/{layout.py, rows.py, amounts.py, dates.py, text.py, header.py, document.py, __init__.py}`, `api/src/normalizer/rules.py`, `api/rules/{statement_labels, ocr_lookalikes, statement_lines}.yaml`, `api/tests/golden_update.py`. + +**tabular-parser:** `api/src/parser/statements/{detect, columns, lines, merge, statement, checks, number_format(display_amount), header(parse_header, _report_date)}.py`, `api/src/parser/{book.py (parse), grid.py, numbers.py, detect.py (classify_sheet), rules.py (StatementPeriodRules, line_codes)}`, `api/rules/{statement_checks, statement_periods, statement_headers}.yaml`, тесты `test_statement.py` (N-1, O-9), `test_statement_synthetic_files.py` (SKIPPED_COLUMN_WARNING). + +**arm-core:** `api/src/domain/{statement_mapping, statement_checks, statement_lines}.py`, `api/src/services/{result_mapping (_map_statement_docs, _insert_statements, map_file), statements, auto_confirm, documents (_check_statement), consolidation_documents (_key, fits, _latest_first)}.py`, `api/src/repositories/statements.py` (insert_statement, statement_amounts, value_flags, form_lines, load_form, _line_amount), `api/src/schemas/statements.py`, `api/src/contract/result.py`, `api/rules/{statement_checks, statement_lines}.yaml`, `api/src/api/endpoints/files/statements.py`, `web/src/widgets/DocumentReview/{statementGrid.ts, formLayout.ts, StatementTable.tsx, ChecksPanel.tsx}`. diff --git a/openspec/changes/unified-statement-normalization/review-2026-10-17-closure-2.md b/openspec/changes/unified-statement-normalization/review-2026-10-17-closure-2.md new file mode 100644 index 0000000..c685a5d --- /dev/null +++ b/openspec/changes/unified-statement-normalization/review-2026-10-17-closure-2.md @@ -0,0 +1,256 @@ +# Проверка закрытия V-1…V-13 и G-1…G-30 (17.10.2026, третий раунд, Sonnet) + +Ревьюер: отдельный агент (модель Sonnet), без права правки; получил design.md после коммита e6400bf, отчёты +`review-2026-10-17-closure.md` (V-n) и `review-2026-10-17-blind.md` (G-n). Маркеры новых проблем — W-n, наблюдения — O-n. + +Отчёт агента ниже без сокращений. + +--- + +# Проверка закрытия V-1…V-13 и G-1…G-30 в обновлённом design.md (коммит e6400bf) + +Файлы не правил и ничего не коммитил. Читал design.md целиком (850 строк), оба отчёта ревью и фикстуры. Сценарии из находок прогонял по новым правилам. Данные проверял скриптами `python3 -I`, для xls — venv парсера с `-I`. + +Итог: 43 находки. +- `closed` — 31. +- `closed, new issue` — 9: V-3, V-13, G-2, G-12, G-16, G-17, G-19, G-25 и ещё G-1 в связке с W-6. +- `partial` — 3: V-4, V-12, G-8. +- `not closed` — нет. + +Новых противоречий найдено 16 (W-1…W-16). Серьёзные из них: +- W-1 — регрессия детектора «только актив», три файла из манифеста. +- W-2 — `confirmed` нигде не хранится. +- W-4 — наследование столбца кодов ломает лист «_2» НПЗ. +- W-3, W-5, W-6, W-9 — средней серьёзности. + +## 1. Таблица + +| Находка | Где закрыто | Статус | Что осталось / новая проблема | +|---|---|---|---| +| V-1 | Шаг 2 п. 1 (а): подпись принимается при доле кодов ≥ 0,6 или кодах ≥ 3; при нескольких кандидатах — больше кодов; «документ из двух строк» = 2 из 2; решение № 3 | closed | Проверил на данных. Унагранде ББ Лист 1: «Коды» (5,33) даёт 1 код из 9, доля 0,11; «Код» (27,21) — 43 из 43. ОФР Лист 1 3кв.25: «Коды» 1 из 25, «Код» 17 из 17. Титульный столбец отсекается, форма берётся верно | +| V-2 | «Код и наименование строки»: пятизначный код подтверждён через четырёхзначного родителя; применяется только к кодам с уверенностью < 100 (Р-12); Excel и чистые OCR-коды дают `info` ядра | closed | Формулировки уточнить в спеке: W-12 (правило родителя), W-13 (границы алгоритма) | +| V-3 | Шаг 4 (`values[].text`); DBML v5.19 `source_text`; конвенция знака «от сохранённого `text`» | closed, new issue | W-8: у правленой ячейки `text` устаревает и участвует в голосовании за конвенцию колонки | +| V-4 | Шаг 2 п. 2: сегмент без шапки и с кодами < 3 «наследует столбец кодов» предыдущего сегмента | partial | W-4: у НПЗ столбец кодов на каждом листе свой, наследование по индексу не работает | +| V-5 | DBML v5.19: `financial_statement_line.needs_review`; PATCH: `line_code_modified` ⇒ `needs_review` строки = false | closed | — | +| V-6 | Шаг 8 п. 2: «ИНН не найден при найденной организации» — `info`, «ни организации, ни ИНН» — `error`; «формат не совпал» — `info` | closed | Согласовано с критерием `done` п. 1 и решением № 1. НПЗ ОФР без ИНН остаётся `done` | +| V-7 | Р-15; «Что уходит из ядра»: вторая форма не автоподтверждается, подтверждение второй — 409; вопрос о склейке — в `07-open-questions.md` | closed | Решено владельцем. Формулировка 409 без оговорки «из одного файла», но между файлами работает автозамена (§12 п. 3), поэтому на практике это только внутри файла | +| V-8 | Шаг 2: роль `title` для всего выше строки заголовка; шаг 3: `missing` только между заголовком и первой строкой с кодом | closed | Межениновская: «Дата 30 \| 9 \| 2025» теперь `title`. Повторная шапка «Коды» (стр. 48 Формы 1) открывает новый сегмент по п. 2, а не `unreadable` | +| V-9 | Шаг 3 п. 2: разлепление «до разбора кода»; шаг 2 п. 1: многострочная ячейка — код, если каждая часть код | closed | — | +| V-10 | DBML v5.19: `recognition_job.statements_version` | closed | — | +| V-11 | Принцип 4, шаг 5 п. 2, «Что уходит из ядра»: словарь ядра хранит только имена, редакции берутся из копии общего кода, `sync-statements --check` | closed | Побочно: коды перечня без имён (1105, 1530, …) дают `info` «не в словаре имён» — безвредно | +| V-12 | PATCH `lines`: переходы, 422 для пары «`ok` без кода», пересчёт родителя `detail`, аудит | partial | W-11: не сказано про `line_code_confidence` после назначения; у строк нет аналога `confirmed` | +| V-13 | «Подтверждение проверкой»: чистые значения в покрытии не нуждаются | closed, new issue | W-7: фраза «ни одним правилом ниже не подсвечиваются» противоречит следующему пункту про непройденное жёсткое соотношение | +| G-1 | Р-12 + «Код и наименование строки»: источник перечня — коды шаблонов банка плюс коды печатных форм 1С; пятизначные через родителя; для Excel — `info` | closed | Перечень всегда неполон: в `ББ. Лист 1` (unagrande-group) есть 1231, которой нет ни в словаре ядра, ни в названных 1С-кодах. Для Excel и чистого OCR это `info`, для кодов с уверенностью < 100 — `needs_review`, что принято. Связанная дыра — W-6 | +| G-2 | Шаг 7: «цель отсутствует/`null`, а хотя бы один член непуст — жёсткое нарушение» | closed, new issue | W-1: симметричный случай (цель есть, все члены пусты) не считается — три фикстуры «только актив» станут `done`. O-2: остаточный риск для строк вне §7 | +| G-3 | То же, что V-3: `values[].text` в контракте v2.0, `source_text` в DBML, `string_value` в прежнем смысле | closed | Семантика `string_value` теперь одна (шаг 4, вводная) | +| G-4 | Шаг 2 п. 3: словарь периодов объединяет `statement_periods.yaml` и шаблоны `dates.py`; `test_parses_real_forms` — обязательные тесты | closed | — | +| G-5 | То же, что V-1 | closed | — | +| G-6 | То же, что V-6 | closed | — | +| G-7 | Шаг 8 п. 3 и «Что уходит из ядра»: `RECOGNIZED` ⇔ конверт `done` ∧ `document.status = done` | closed | O-1: `document.status = done` избыточен, так как конверт `done` уже означает «все документы `done`». Прежнее поведение сохранено | +| G-8 | Шаг 1 п. 6: слабое продолжение присоединяется, только если коды в диапазоне `type_hint`; иначе отдельный кандидат + `error`; шаг 2 п. 2 | partial | W-4 (наследование); W-10 (отдельный кандидат: «не документ `info`» против `error`) | +| G-9 | Решение № 16: `ref` OCR = `row:<row_index>[#<k>]`, Excel = `<лист>!<A1>`, уникален в документе | closed | Мелочь, W-15: для `Cell.ref` у OCR позиция без столбца не уникальна на уровне ячейки | +| G-10 | Р-13: 409 `LINES_UNRESOLVED` при `unreadable` / `missing` с непустым `amount`; `detail` не попадает в ЕХ | closed | — | +| G-11 | DBML v5.19: `line.needs_review`, `recognition_job.statements_version` | closed | Маркера G-11 в тексте нет, но содержание внесено | +| G-12 | PATCH `values[].confirmed`: `needs_review` = false, `is_modified` не ставится, аудит | closed, new issue | W-2: флаг подтверждения нигде не хранится, следующий пересчёт его теряет | +| G-13 | PATCH `columns: [{column_id, period_date}]`: снимает `error` «колонка без даты», пересчитывает `is_current`; `report_date: null` обнуляет относительные | closed | W-16: исключение из правила F-8 «`error` сохраняются» нужно оформить явным списком | +| G-14 | Р-1: пакет владеет своими типами, хост конвертирует; golden пакета на синтетике; `sync-statements --check` в `make check-full` | closed | — | +| G-15 | Р-14: раскладка по коду остаётся; строки со статусами — под ближайшей предыдущей размещённой; `detail` скрыты | closed | W-15: не определено место `missing`-строки, у которой нет предыдущей размещённой (aep_3 ОФР, «Выручка» — первая строка таблицы) | +| G-16 | Шаг 4 п. 1: `parse_amount(text, source)`, «−» как знак, тонкие пробелы, запятая по источнику | closed, new issue | W-14: квантование тихо округляет OCR-мусор вроде «12,3456»; правило точки-тысяч (sg#37) не названо | +| G-17 | Шаг 6 п. 1: `form_variant = simplified` (ОКУД 0710096 или структура), свой набор соотношений и перечень | closed, new issue | W-5: в `statement_headers.yaml` ОКУД 0710096 уже мапится на `DISCLOSURE` | +| G-18 | Принцип 5: ИНН — только цифры; шаг 2 п. 1: порядок «пробелы и переносы → двойники → проверка», `\|` → 1 только если результат попадает в диапазон | closed | — | +| G-19 | Шаг 6 п. 1: `DISCLOSURE` по `type_hint` — без шагов 2–7; по кодам — шаги 2–5 и отбрасывание строк | closed, new issue | W-9: диапазоны «1xxx / 2xxx / 3xxx–6xxx» шире словарных | +| G-20 | Шаг 8 п. 2: уровни; «организация не найдена при найденном ИНН» — `info`, «ни того ни другого» — `error` | closed | — | +| G-21 | Шаг 3 п. 2: разлепление, только если хотя бы одна часть — код | closed | При известном `type_hint` «44 30» (=4430) вне диапазона ББ, не множится. При неизвестном 4430 станет кодом из-за расширения диапазонов (W-9) | +| G-22 | Шаг 2 п. 1: «краевая пунктуация» («1220 ,» → 1220) | closed | — | +| G-23 | PATCH: `report_date: null` обнуляет `period_end` относительных колонок | closed | — | +| G-24 | Р-15 | closed | — | +| G-25 | `line_refs` — список; `document_issue`: список `line_id`, очистка при перезагрузке, `core`-записи заменяются | closed, new issue | W-3: устаревшие нарушения источника `service` после PATCH | +| G-26 | Принцип 3: ядро добавляет только `info` по словарю имён и перечню | closed | — | +| G-27 | Утверждение «в трёх из четырёх» в тексте дизайна отсутствует | closed | Маркера нет, исправлять нечего | +| G-28 | Шаг 2 п. 1: словарь подписей расширен («Код строки») | closed | — | +| G-29 | Шаг 2 п. 7: одно правило столбца наименования | closed | В запасной ветке («ближайшая текстовая левее кода») пропало «кроме Пояснения»; для столбцов левее кода из 66н не критично | +| G-30 | PATCH: аудит `RECOGNIZED_DATA_EDIT` с `before / after` по коду, статусу, родителю; назначенный код снимает `needs_review` | closed | — | + +## 2. Разбор незакрытых проблем и новых противоречий + +### W-1 (important). «Посчитано, если хотя бы один член непуст» (F-2) + G-2 теряют детектор односторонней формы + +Где: шаг 7, пункты «цель отсутствует» и «соотношение считается посчитанным». + +В G-2 «член» — это правая часть соотношения, отдельно от цели. Значит, цель есть, а все члены пусты — «не проверяется». + +Сценарий: «ББ. Лист 1» с одним активом. 1600 есть, 1700 нет. +- `1600 = 1700`: цель 1600 непуста, член 1700 отсутствует, соотношение не считается. +- `1700 = 1300 + 1400 + 1500`: цели нет, членов нет, тоже пропуск. +- Остальные соотношения сходятся, org/ИНН и период есть, строки `ok`, значения чистые — получается `done`. + +Сегодня эти файлы `needs_check`, потому что отсутствующий член считается нулём (`tabular-parser/api/src/parser/statements/checks.py` ~122; `arm-core/api/src/domain/statement_checks.py::run_checks`). Манифест `tabular-parser/api/tests/statements_manifest.yaml`: строки 11, 24, 32 — три файла (unagrande-co 2024-FY, unagrande-group 2024-FY и 3кв.25), `failed_relations: ["1600 = 1700"]`. Половина баланса автоподтвердилась бы. + +Предложение: соотношение посчитано, если непуста цель или хотя бы один член; пропуск только когда непусто ничего. Тогда правило «все null — ничего не подтверждает» (F-2) сохраняется, а G-2 становится его зеркалом. Обновить golden и манифест. + +### W-2 (important). `confirmed` (G-12) не хранится, пересчёт его сбросит + +Где: PATCH `values[].confirmed`, «Подтверждение проверкой», список DBML v5.19. + +Для правки есть `is_modified`, для акцепта есть `checks_accepted_*`. Для `confirmed` в v5.19 колонки нет: ни флага, ни `confirmed_by/at`, только `audit_event`. Пересчёт общим кодом «после PATCH» применяется ко всем значениям. + +Сценарий: ячейка с уверенностью 71, верная, в строке вне §7. Аналитик подтвердил её, `needs_review` = false. Любой следующий PATCH другой ячейки пересчитывает её обратно: «нуждается в покрытии и не покрыто» даёт true. Правило G-12 не работает дольше одного PATCH. + +Предложение: добавить в `financial_statement_value` признак (`review_confirmed_by/at` или `is_confirmed`) и вход «подтверждено» в общую функцию `needs_review`. Альтернатива: пересчёт монотонен для подтверждённых, но тогда сломанное соотношение должно возвращать true. + +### W-3 (medium). `document_issue`: устаревшие нарушения источника `service` + +Где: «Что уходит из ядра», DBML v5.19. + +Сказано только, что записи источника `core` заменяются при каждом пересчёте. Нарушения §7, пришедшие от сервиса (`kind` relation/soft, `source = service`), после правки значения остаются и показываются как действующие. Рядом появляются новые `core`-записи — дубли и устаревшее. + +Предложение: при пересчёте после PATCH заменять все записи `relation` / `soft` независимо от источника. Предупреждения `warning` источника `service` сохранять, кроме явно снимаемых (W-16). + +### W-4 (important). V-4 / G-8: «наследование столбца кодов» не работает на геометрии НПЗ + +Где: шаг 2 п. 2, решение № 3. + +У НПЗ столбец кодов на каждом листе свой. Проверено openpyxl, номера столбцов с 1: +- ББ: листы 1–5 — 13, 7, 7, 7, 12. +- ОФР: лист 1 — 10, лист `_2` — 6, лист `_3` — 12. +- Лист `_2` (единственная строка 2400) шириной 9 столбцов. + +Если «наследуется» значит «тот же индекс, что в предыдущем сегменте» (столбец 10), то в `_2` его просто нет. Получается ошибка сегмента и документ `needs_check` вместо `done`. То же с `st07_single_code_continuation` (там индексы совпадают, но принцип тот же). Прежний `relaxed` (`statement.py` `_continuation`, `min_codes_continuation: 1` в `statement_headers.yaml`) ищет столбец по содержимому, а не наследует индекс. + +Предложение: заменить формулировку на ослабленный поиск для сегмента не первого, без шапки, с кодами < 3: столбец с наибольшим числом кодов из диапазона формы, минимум 1. Оставить предупреждение `error`, если и так не найден. Сам индекс предыдущего сегмента не использовать. Сохранить проверку диапазона `type_hint` (`_weak_blocker`). + +### W-5 (medium). ОКУД 0710096 как признак упрощённой формы конфликтует со словарём типов + +Где: шаг 6 п. 1 (G-17), `evidence`. + +В `tabular-parser/api/rules/statement_headers.yaml` (`okud: DISCLOSURE: '\b07100(0(3|…|9)|(1|…|9)\d)\b'`) значение 0710096 попадает в `DISCLOSURE`. По шагу 1 п. 4 `type_hint` = `DISCLOSURE`, коды строк 1xxx → по шагу 6 это «противоречие» → тип по кодам + `error` → `needs_check`. Пока словарь не правится, упрощённая форма с этим ОКУД никогда не будет `done`, как бы ни работал вариант G-17. + +В фикстурах 0710096 нет вообще (только в `admin/source/СУЖЦ…xls`). Утверждение, что упрощённые ББ/ОФР несут именно этот ОКУД, ничем не подтверждено. По памяти упрощённые бланки 66н идут с обычными 0710001/0710002, а 0710096 относится к комплекту отчётности. Это надо подтвердить на образце или у владельца; сам я не верифицировал. + +Предложение: либо убрать ОКУД из признаков упрощённой формы (оставить структурный: есть 1600, нет 1100 и 1200), либо явно исключить 0710096 из `DISCLOSURE` в словаре. Образец упрощённой формы — в фикстуры. + +### W-6 (medium). Р-11 vs Р-12: код, исправленный из двойников с уверенностью 100, не проходит ни одной проверки + +Где: принцип 5 / Р-11 и «Код и наименование строки» / Р-12. + +Р-11 велит заменять двойники и в кодах, а для суммы вводит `is_recognized = false`, чтобы её проверил §7. У кода такого флага нет. Структурное подтверждение применяется только при `line_code_confidence < 100`. Код «I140» или «1l50», прочитанный ИС с уверенностью 100, получает `ok` без `needs_review`. §7 перепутанные соседи не ловит (сумма раздела та же), поэтому запись сохраняется без проверки. + +Предложение: считать код после замены двойника «неуверенным» (эффективная уверенность < 100, или признак `code_corrected`) и пропускать через структурную проверку. Это одна строка в «Код и наименование строки». + +### W-7 (minor). Внутреннее противоречие в «Подтверждении проверкой» + +Первый пункт: чистые значения «ни одним правилом ниже не подсвечиваются (V-13)». Пятый пункт: `true` «у всех ячеек жёсткого соотношения … независимо от уверенности». Второе включает чистые Excel-значения, поэтому первое надо сузить: «ни правилом покрытия, ни правилом знака». + +### W-8 (minor). Устаревший `text` правленой ячейки в голосовании за конвенцию знака + +Конвенция — форма записи у большинства членов `abs_terms` с `is_recognized = true`, считается от сохранённого `source_text`. Если аналитик исправил знак у члена, `source_text` прежний, а `amount` новый, и ячейка голосует старой формой. Что делает PATCH с `is_recognized`, не сказано. + +Предложение: исключить из голосования ячейки с `is_modified` (или считать форму от знака `amount`). + +### W-9 (medium). G-19: диапазоны кодов шире словарных + +Где: шаг 2 п. 1, шаг 6 п. 1. + +Словарь парсера: 1100–1799, 2100–2999, 5000–5999 (`tabular-parser/api/rules/statement_headers.yaml`, `line_codes.ranges`). В дизайне «1xxx — ББ, 2xxx — ОФР, 3xxx–6xxx — прочие», при неизвестном `type_hint` объединение. + +Два риска: +- Годы в шапках (2024, 2025) попадают в 2xxx. В НПЗ ОФР в столбце 16 стоит «2025». +- Значения в тысячах от 3000 до 6999 считаются кодами, а столбец сумм начинает конкурировать со столбцом кодов за «наибольшее число» (кодов ≥ 3, доля ≥ 0,6 — слабая защита). Для разлепления G-21 «44 30» (=4430) тоже становится кодом, если `type_hint` неизвестен. + +Предложение: в дизайне явно записать числовые диапазоны (оставить 1100–1799, 2100–2999, расширить DISCLOSURE до 3000–6999 только при неизвестном `type_hint` и тестами) вместо «1xxx / 2xxx». + +### W-10 (minor/medium). Одинокий лист-продолжение: «не документ, `info`» против `error` + +Где: шаг 6 п. 1 и шаг 8 п. 2. + +Лист «Справка» с кодом 1370 после ОФР не присоединяется (шаг 1 п. 6) и становится отдельным кандидатом. У него кодов < 3 и `type_hint` нет. Шаг 6 п. 1 говорит «не документ, `info`», шаг 8 п. 2 — «сегмент с содержимым без колонки кодов (в том числе потерянный лист-продолжение) — `error`». Данные листа теряются, `done` у соседних документов возможен: нарушается принцип 2. + +Предложение: в шаге 6 п. 1 добавить исключение — лист с числами и не присоединившийся продолжением даёт файловое `error` (и строки сохраняются как `unreadable` / `detail`, если возможно). + +### W-11 (minor). Остатки V-12 / G-30 + +- После назначения кода (`unreadable` / `missing` / `detail` → `ok`) не сказано, чему равна `line_code_confidence` (100?). Сейчас остаётся прежняя, возможно 25. +- У строки есть `needs_review` (структура кода), но нет `lines[].confirmed`. Код с уверенностью 50, стоящий не по порядку, но верный, снимается только повторным назначением того же кода, которое в тексте как «другой код» не описано (аналог G-12 для строк). + +### W-12 (minor). Правило родителя пятизначного кода + +В «Код и наименование строки»: «ближайшая строка выше с теми же четырьмя первыми цифрами, иначе код из первых четырёх цифр — правило `subline_parents`». В реальном коде (`arm-core/api/src/domain/statement_checks.py::subline_parents`) ближайшая четырёхзначная строка выше с теми же тремя цифрами, иначе код из первых четырёх. Первая половина фразы при буквальном чтении матчится на пятизначного соседа (11101 выше 11102). Привести к тексту кода. + +### W-13 (minor). Границы алгоритма подтверждения структурой + +- Наибольшая подпоследовательность не единственна. Для двух поменявшихся соседей (1230, 1220) подтверждается один из двух, и не определено, какой остаётся без подтверждения. Если выпавший код имеет уверенность 100, он освобождён от проверки, а второй числится «подтверждённым». +- «Хотя бы в одной из проверяемых редакций»: берётся объединение подпоследовательностей двух редакций или одна общая? Правка: выбирать редакцию с наибольшей подпоследовательностью. +- Родитель с уверенностью 100 (освобождён) для пятизначного дочернего кода считается «подтверждённым» или нет? Записать. + +### W-14 (minor). OCR: тихое округление и правило точки + +Решение № 13 («2 знака, half-up») в общем `parse_amount` для OCR-текста. Сегодня шлюз отвергает такие значения: `scan-gateway/api/src/normalizer/ocr/document.py:~143-150`, решение № 30: «0,125», «12.3456» — артефакт, `amount = null`, текст в `string_value`. По дизайну «12,3456» станет 12,35, причём `is_recognized` не оговорён. Оставить квантование только для числового `value` (Excel), а для OCR-текста сохранить `null` + `string_value`. + +Правило точки-тысяч (`837.994`, `1234.567` — sg#37, `amounts.py` `_DOT_THOUSANDS_RE`) в шаге 4 п. 1 не названо. Указать, что оно сохраняется для `ocr` и как ведёт себя для `excel`. + +### W-15 (minor). Экран и `ref` + +- Р-14: у `missing`-строки без предыдущей размещённой (aep_3 ОФР, «Выручка» — первая строка таблицы) место не задано. Предложение: в начало первого раздела. +- `Cell.ref` у OCR — `row:<row_index>` без столбца: уникально для строки, но не для ячейки. В шаге 1 сказано «уникальная в документе», уточнить, что это `RawLine.ref`. + +### W-16 (minor). Исключения из «`error` сохраняются после правки» (F-8) + +PATCH `columns` снимает `error` «колонка без даты» (G-13). Остальные `error` сервиса (несовпадение колонок сегмента, «строка заголовка не найдена», тип по кодам и др.) сохраняются. Записать явный перечень «какая операция PATCH какое `error` снимает», иначе реализация разойдётся. + +### Наблюдения (не противоречия) + +- O-1. Вторая часть `RECOGNIZED` («`document.status = done`») избыточна при требовании «конверт `done`»: конверт `done` означает «все документы `done`». Один плохой документ блокирует автоподтверждение всех остальных в файле. Так же было и раньше (`job_done` в `statement_status`). Если нужна независимость документов, это отдельное решение. +- O-2. Строки вне §7 с утерянным кодом (справочные 25xx / 29xx и подобные) уходят в `detail` без нарушения. Раньше это был счётчик, но любое предупреждение шлюза давало `needs_check`. Теперь такая строка скрыта, а документ может стать `done` и автоподтвердиться. Принято Р-8, но шапка Р-13 ограничена `unreadable` / `missing`, а не `detail`. +- O-3. В критерии `done` нет уверенности полей шапки. Шлюз требовал 100 для ИНН и организации. Остаётся сверка ядра (`MISMATCH`). Намеренно ли это ослабление, в дизайне не сказано. + +## 3. Согласованность правок между собой + +Проверил и признал согласованным: +- **Р-12, G-1, V-2 и перечень V-11.** Перечень (superset словаря ядра) — в общем коде, имена — только в ядре, `--check` в одну сторону (словарь ⊂ перечень) достаточен. Пятизначные коды, 1105, 1530, 2420, 2460, 2930 покрыты; Excel и чистые OCR-коды дают `info`. Оговорки: W-6 (двойники), W-12 и W-13. +- **G-2 + Р-8 + G-17.** Для «БАЛАНС» без кода: `detail` → цель 1600 отсутствует → жёсткое нарушение, подсвечивается родитель. Для сегмента «Лист 2» цель 1600 отсутствует, член 1700 непуст → нарушение, документ `needs_check` (как в golden). Упрощённая форма использует свой набор. Оговорки: W-1 (симметричный случай), W-5 (ОКУД), O-2. +- **G-3 и V-3.** `text` и `string_value` с разными ролями; `source_text` в DBML; контракт и DBML соответствуют. Оговорка: W-8. +- **V-1 и G-5 «из двух строк».** Для двухстрочного документа 2/2 = 1,0, титульный столбец отсекается: проверено на Унагранде (доли 0,11, 0,12, 0,04). +- **Уровни V-6 / G-6 / G-20 и критерий `done`.** Список `error` и `info` согласован между п. 1 и п. 2 шага 8 и решением № 1. НПЗ ОФР без ИНН — `done`. +- **Статус конверта в `RECOGNIZED` (G-7).** Согласовано с шагом 8 п. 3. Замечание — O-1. +- **PATCH `lines` / `columns` / `confirmed` и аудит.** Один PATCH — один пересчёт, аудит `RECOGNIZED_DATA_EDIT` с `before / after`. Оговорки: W-2, W-11, W-16. +- **DBML v5.19 и контракт v2.0.** Проверил соответствие: + - `line_status`, `line_code_text`, `row_index`, `ref` → `source_ref`, `parent_ref` → `parent_row_index`, `needs_review` строки и значения; + - `values[].text` → `source_text`, `columns[].period_kind`, `form_variant` в meta; + - `warnings[]` и `checks` → `document_issue`, `statements_version` → `recognition_job`; + - снятие уникальности `document` и `(meta_id, line_code)` плюс порядковый номер формы. + Не хватает только флага подтверждения (W-2). +- **Раскладка экрана (Р-14).** Совместима с Р-4 (дубли кодов в порядке файла), шаблонными строками и `detail` под родителем. Оговорка — W-15. +- **Р-15 и автозамена.** Автозамена между файлами (§12 п. 3) убирает дубли ББ/ОФР между файлами, поэтому Р-15 действует только внутри файла. + +## 4. Что смотрел + +**Дизайн и ревью** (`.wt/normalization/docs/openspec/changes/unified-statement-normalization/`): +- `design.md` целиком, включая «Целевую картину», «Решения владельца Р-1…Р-15» и «Decisions made without discussion» 1–19; +- `review-2026-10-17-closure.md` (V-1…V-13) и `review-2026-10-17-blind.md` (G-1…G-30) целиком. + +**Правила и модель:** +- `docs/context/05-domain-rules.md` §12 п. 3 (автозамена), строки 680–695; +- `docs/model/data-model-v5.18.dbml`: `document` (757–780), `recognition_job`, `financial_statement_meta/column/line/value`. + +**tabular-parser:** +- `api/rules/statement_checks.yaml`, `statement_headers.yaml` (`okud`, `line_codes`); +- `api/src/parser/statements/checks.py` (`run_statement_checks`); +- `api/tests/statements_manifest.yaml`; +- `CLAUDE.md`. + +**scan-gateway:** `api/src/normalizer/ocr/amounts.py` (`parse_amount`), `document.py` (`_value`). + +**arm-core:** +- `api/src/domain/statement_checks.py` (`subline_parents`, `_subline_rules`, `run_checks`); +- `api/src/services/statements.py` (PATCH-цикл, пропуск несуществующей ячейки); +- `api/rules/statement_lines.yaml`. + +**Фикстуры:** +- `fixtures/golden/expected_statement_parse/**`: `Б.Б. Лист 2`, `ОФР. Лист 2`, `st05` — статусы и предупреждения; +- `fixtures/ocr/expected_normalize/*.json` и golden BALANCE/INCOME — коды вне словаря (найдены 1105, 1231, 2930); +- `fixtures/statements-xlsx/npz/2025-Q3/*.xlsx` — столбцы кодов по листам; +- `fixtures/statements-xlsx/mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` — повторная шапка и title; +- `fixtures/statements-xlsx/synthetic/*.xlsx`; +- `unagrande-co/2024-FY/Б.Б Лист 1.xls`, `unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls`, `ББ. Лист 1 3кв.25.xls` — подписи «Код» / «Коды» и доли кодов. + +Скрипты — в scratchpad (`npz.py`, `g2.py`, `ux.py`, `xl.py`, `codes.py`). Не смотрел: arm-web; `services/documents.py` и `auto_confirm.py` — для Р-13 и Р-15 опирался на текст дизайна, а не на код. diff --git a/openspec/changes/unified-statement-normalization/review-2026-10-17-closure.md b/openspec/changes/unified-statement-normalization/review-2026-10-17-closure.md new file mode 100644 index 0000000..465ef15 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/review-2026-10-17-closure.md @@ -0,0 +1,148 @@ +# Повторное ревью design.md — проверка закрытия F-1…F-25 (17.10.2026, второй раунд) + +Ревьюер: отдельный агент (модель Fable), без права правки; получил обновлённый design.md (после коммита 63f134b) и +`review-2026-10-17.md`. Задание: по каждой находке F-1…F-25 найти место закрытия, прогнать сценарий через новые +правила, оценить статус; отдельно — согласованность правок между собой. Маркеры новых проблем — V-n. + +Отчёт агента ниже без сокращений. + +--- + +# Повторное ревью design.md (unified-statement-normalization) — закрытие F-1…F-25 + +Ревьюер: независимый агент, без права правки. Проверено: `design.md` целиком (763 строки), `review-2026-10-17.md`, §7/§11/§11.1/§12 `05-domain-rules.md`, контракт v1.2, DBML v5.18, код трёх сервисов (через codegraph + Read), фикстуры OCR (4 успешных json, golden-эталоны) и Excel (НПЗ, Унагранде ×2, Межениновская, синтетика) скриптами `python3 -I` / venv парсера. Решения Р-8…Р-11 не пересматривались. + +## 1. Таблица по находкам + +| F-n | Где закрыто | Статус | Что осталось / новая проблема | +|---|---|---|---| +| F-1 | Шаг 7 (abs_terms «подтверждает только величину»), «Подтверждение проверкой» (знак по конвенции колонки), шаг 4 п. 1 (`}`/`]` — закрывающая скобка), Р-7, решение № 10 | closed, new issue | Правило верно на данных (aep_3 ОФР: все члены abs в скобках, `(7744536}` подтверждается, `{38378)` — подсвечивается). Но «форма записи знака» не доезжает ни до контракта, ни до БД — ядро не может пересчитать конвенцию после PATCH (**V-3**); область применения правила не ограничена сомнительными ячейками (**V-13**) | +| F-2 | Шаг 7 («посчитано, если хотя бы один член непуст»), «Подтверждение проверкой» (покрыто ⇒ `amount` не `null`), Р-7 | closed | Сценарий «колонка залита полосой» даёт `needs_review = true` у всех мусорных ячеек; сценарий «1600 мусор, 1100/1200 читаемы» — нарушение, все три подсвечены | +| F-3 | Шаг 3 (статус `detail`, родитель — ближайшая строка с кодом выше в сегменте), Р-8, решение № 9, «Что уходит из ядра» (экран скрывает `detail`) | closed, new issue | По фикстурам все 90 строк без кода с суммами в 8 OCR-таблицах — `detail`, единственная `missing` — «Выручка» aep_3 (код 2110 в колонке значений, верно). У Excel НПЗ/Унагранде `missing` нет. Но роль строк титульного блока над заголовком не определена — у Межениновской «Дата 30 \| 9 \| 2025» станет `missing` (**V-8**) | +| F-4 | Принцип 2, шаг 3 (`row_index`, `ref`), шаг 8 п. 5, DBML v5.19 (`row_index`, `source_ref`, `parent_row_index`), «Экран показывает строки как в файле» | closed | — | +| F-5 | Шаг 2 п. 1 (а) «подпись принимается только с кодом под ней, последняя над первой строкой с кодом», п. 4 (обе ветки парсера сохранены), решение № 3 | partial | Защита «хотя бы один код под подписью» пробивается ОКОПФ **12267** (АО) в титульном блоке 1С: Унагранде ББ Лист 1 и Унагранде Групп ОФР 3кв.25 — под «Коды» стоит 12267 (1226 ∈ [1100, 1799]); правила выбора между двумя столбцами-кандидатами нет (**V-1**, critical). Ветка «заголовок по периодам» перенесена — верно | +| F-6 | Шаг 2 п. 3 (эффективная дата), шаг 8 п. 1 («дата отчёта **или** самая поздняя абсолютная дата колонок»), п. 2 (`info` «дата отчёта не разобрана при наличии дат колонок»), решение № 12 | closed | Совпадает с `statement_period` ядра (`statement_mapping.py:73-80`) | +| F-7 | Шаг 9 (v2.0, порядок «сначала ядро», два маппера), Р-9 | closed | Паттерн `^1\.\d+$` в `contract/common.py:33` поднимается до `^(1\|2)\.\d+$`; запрос `recognition-request` остаётся 1.x (шаг 0) — не затронут. В спеке нужно задать, чем заполняются новые поля при приёме 1.x (`line_status = ok`, `row_index` по порядку, `needs_review` — старое правило) — не противоречие, а пробел спеки | +| F-8 | Шаг 8 п. 1 (одна функция `done`), «Подтверждение проверкой» (is_modified ⇒ false, акцепт, `error` сохраняются), DBML v5.19 (`line_status`, `line_code_text`, `row_index`, `value.needs_review`, `document_issue`), решение № 11 | partial | Пп. 2–4 закрыты. П. 1 закрыт для значений, но **`needs_review` строки** (F-9) в DBML v5.19 не добавлен, и правило «назначение кода снимает `needs_review` строки» не записано (**V-5**). Пересчёт конвенции знака после PATCH невозможен без формы записи (**V-3**) | +| F-9 | «Код и наименование строки» (упорядоченный перечень по редакциям, порядок бланка, редакция по эффективной дате / обе, равные соседние допустимы), шаг 5 п. 2, Р-5 | closed, new issue | Итоги после слагаемых — верно. Но пятизначные подстрочные коды (11101, 12101, 23201 — все выгрузки 1С: НПЗ, Унагранде) ни в какой перечень бланка не входят → `needs_review` у каждой такой строки → НПЗ ББ/ОФР и Унагранде ОФР из `done` уходят в `needs_check` (**V-2**, critical). Алгоритм «положение относительно соседних подтверждённых» циклически определён. Порядок/состав редакций теперь в двух местах (**V-11**) | +| F-10 | Шаг 2 п. 3 (`period_kind = relative:<вид>`, `period_end = null`), «Что уходит из ядра» (PATCH даты → ядро досчитывает общим кодом), Р-6, DBML `column.period_kind` | closed | Согласовано с F-6: эффективная дата из колонок даёт относительным датам опору и без правки | +| F-11 | «Что уходит из ядра» (снять уникальность `document`, порядковый номер формы), Р-10 | closed, new issue | Потеря устранена, но не сказано, что делать с двумя документами одного типа и периода у одного ЮЛ (чек-лист, консолидация, `_pair_document`), и как быть с половинками формы (OCR: «одна таблица = одна форма, склейки нет») — склейки внутри файла нет (**V-7**) | +| F-12 | Шаг 8 п. 2 (таблица уровней), решение № 1 | closed, new issue | Все блокираторы из ревью перечислены (json набора, `BACKEND_PARSING_ERROR`, лист без кодов, сегменты, колонка без даты, заголовок, тип), xlsb/дробные решены. Но «ИНН не распознан» → `error` противоречит критерию `done` «(организация **или** ИНН)» и ломает НПЗ ОФР без ИНН (`inn_optional_for: [INCOME]`, сегодня `done`); «формат не совпал с `file_type`» → `error` противоречит собственному принципу решения № 1 (**V-6**) | +| F-13 | Шаг 7 («не сошлось» = вне допуска ±1 **и** вне округления 0,001 %; округление и мягкие → `soft`, `info`), «Подтверждение проверкой» (покрыто в пределах допуска или округления; мягкие подсветки не дают), шаг 8 п. 1 | closed | НПЗ (1600 = 1700 на 1,94 при ~17 млрд) и Унагранде (мягкие 2400/2410) остаются `done` по этому пункту | +| F-14 | «Назначение кода строке» (`PATCH …/statement lines: [{line_id, line_code \| null, line_status}]`, аудит, пересчёт, `missing → detail`, `detail → ok`), экран: поле кода у `unreadable`/`missing`/`detail` | closed | Мелкие пробелы спеки: переход `ok → detail`, недопустимая пара `line_code: null` + `line_status: ok`, `parent_row_index` при смене статуса (**V-12**, minor) | +| F-15 | «Что уходит из ядра» (одна функция «агрегат формы по коду», перечислены `_line_amount`, `statement_amounts`, `subline_parents`/`parent_code`, `formLayout`, `_resolve_cells`, `metrics.fields_*`), Р-4 | closed | Все точки из ревью названы. Уточнить формулировку: сумма — по строкам `ok` (шаг 7 говорит это, «Что уходит из ядра» — «сумма значений по колонке» без оговорки) | +| F-16 | Шаг 1 (`segment_breaks`, «граница листа — всегда граница сегмента»), шаг 2 п. 2 (своя раскладка, по числам без шапки, сопоставление по порядку, число не совпало → `error`), п. 6 шага 1 | closed, new issue | Сегмент-продолжение **без шапки и с 1–2 кодами** (НПЗ ОФР лист `_2` — единственная строка 2400, синтетика st07) по правилу п. 1 (б) «кодов ≥ 3» столбца кодов не получит; решение № 3 снимает `relaxed`, ссылаясь на склейку в шаге 1, но склейка не помогает — раскладка у сегмента своя (**V-4**) | +| F-17 | Р-1 (пакет `arm_statements` с тестами и конфигом линтеров, импорт по имени пакета, `service.statements_version`, `sync-statements --check` в docs, словари со своим загрузчиком), шаг 9, «Что уходит из ядра» (версия в `recognition_job`, расхождение — `error`) | closed | Все 5 пунктов закрыты. `recognition_job` не имеет колонки под `statements_version` (есть только `service_version`), в список DBML v5.19 не внесена (**V-10**, minor) | +| F-18 | «Что уходит из ядра» (`RECOGNIZED ⇔ done ∧ ИНН ≠ MISMATCH ∧ период — конец квартала`), шаг 8 п. 1 («квартал — знание ядра») | closed | — | +| F-19 | Принцип 5, Р-11 (двойники в суммах и кодах ⇒ `is_recognized = false`, далее §7 с оговорками F-1/F-2), шаг 4 п. 2 («замена двойника — исправление») | closed | Решение владельца, зафиксировано; §11.1 п. 1 помечен к отмене. Данные: в 4 json двойников в суммах/кодах нет (`(Ю4)` в aep_6 — «Ю» не в списке двойников, остаётся грязной) | +| F-20 | Шаг 4 п. 1 (квантование half-up, 16 целых цифр, дата/логическое → `null` + `string_value`, догадка > 16 цифр → `null`), решения № 6, № 13 | closed | — | +| F-21 | Шаг 2 п. 1 («код — текст без пробелов и переносов»), шаг 3 п. 1 («1 100» → 1100) | closed | Порядок с разлеплением (F-25) не оговорён: «переносы убираются» в п. 1 против «N частей через перевод строки» в п. 2 (**V-9**, minor) | +| F-22 | Шаг 6 п. 1 (5xxx и прочие → `DISCLOSURE`; без кодов — `type_hint`; `DISCLOSURE` без шагов 2–7), решение № 2 | closed | Форма 3/4 (3xxx/4xxx не в диапазонах, label не в словаре) → «не документ, `info`» — как сегодня (golden aep_8_form4 = `error` по ответу ИС); согласовано | +| F-23 | Шаг 1 (`locator file:…`), шаг 6 п. 3, шаг 8 п. 3 (`RECOGNITION_FAILED` / `INVALID_DOCUMENT` — различие записано), решение № 5 | closed | — | +| F-24 | Шаг 2 п. 1 («новое поведение для обоих: шлюз сегодня пропускает со счётчиком, кодом считает любое целое») | closed | — | +| F-25 | Шаг 3 п. 2 (разлепление и при нечитаемой части → `unreadable`; число частей не совпало → одна строка, многострочное значение → `null` + `string_value`) | closed | См. V-9 о порядке операций | + +Итого: closed — 15, partial — 2 (F-5, F-8), closed с новой проблемой — 8 (F-1, F-3, F-9, F-11, F-12, F-16 + мелкие у F-14, F-17, F-21). + +## 2. Разбор незакрытых и новых проблем + +### V-1 — critical — F-5: защита «под подписью есть код» пробивается ОКОПФ в титульном блоке 1С; тай-брейка между столбцами-кандидатами нет +**Где:** шаг 2 п. 1 (а), решение № 3. +**Что проверил.** Скриптом по xls/xlsx прошёл все ячейки `^код(ы| строки)?$` и содержимое столбца под ними: +- `unagrande-co/2024-FY/Б.Б Лист 1.xls`: «Коды» r6c34 → под ней `0710001, 31, 47888105, 3249004697, 10.51.3, **12267**, 384, 7716021332, 1037739271701`; «Код» r28c22 → `1110, 11101, 11102, …`. +- `unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls`: «Коды» r3c18 → `0710002, 30, 69583357, 7728757141, **12267**, 384`, а дальше в том же столбце **значения таблицы** (`За Январь - Сентябрь 2024 г.`, `287017990.57`, `-302669265.62`…) — титульный столбец совпал с колонкой сумм. +`12267` (ОКОПФ «АО») по правилу дизайна — код: 4–5 цифр, первые четыре `1226 ∈ [1100, 1799]` (`statement_headers.yaml:86`; `columns.py:63-71` `line_code`). Для ООО (ОКОПФ 12300) то же. У НПЗ и Межениновской ОКОПФ в этом столбце нет — там защита работает. +**Сценарий.** Два столбца удовлетворяют (а). Правило «последняя такая подпись над первой строкой с кодом»: если «первая строка с кодом» — глобально по сетке, это r13 (ОКОПФ) → единственная подпись над ней «Коды» r6 → столбец c34 → каждая строка формы становится `unreadable`/`missing`, у Унагранде Групп ОФР столбец сумм объявляется кодами. Если «первая строка с кодом» — по столбцу, кандидатов два, а правила выбора в дизайне нет; реализация «первый найденный сверху/слева» выберет титульный блок. Сегодня парсер защищён порядком: сначала столбец по подсчёту (`find_code_column`, `columns.py:74-95`: кодов ≥ 3, доля ≥ 0,6), потом подпись только в этом столбце (`_find_header_row`, `:127-146`). +**Как закрыть.** Требовать под подписью не «хотя бы один код», а долю кодов среди непустых ≥ `min_share` (под «Коды» r6c34 — 1 из 9 = 0,11, под «Код» r28c22 — ~1,0; «документ из двух строк» сохраняется: 2 из 2); при нескольких кандидатах — столбец с наибольшим числом кодов. Либо вернуть порядок парсера (подсчёт → подпись в найденном столбце → заголовок по периодам), а исключение для малых таблиц делать явной веткой. Любой вариант зафиксировать в решении № 3. + +### V-2 — critical — F-9 / Р-5: пятизначные подстроки не входят ни в один перечень бланка → каждая выгрузка 1С с детализацией теряет `done` +**Где:** «Код и наименование строки», шаг 5 п. 2, Р-5. +**Что проверил.** Пятизначные коды в фикстурах: НПЗ ББ — `11101…11103, 11501…11509, 11901, 12101…12106, 12201…, 12501…, 13401…, 15201…`, НПЗ ОФР — `23201…23203, 23301, 23401…, 25101, 25102`; Унагранде ББ — `11101…11507`. Это подстроки «в том числе» 1С (§12 п. 5: «пятизначные подстроки отдаются как обычные строки»; §7 «родитель = Σ подстрок», `statement_checks.py:141-184` `subline_parents`/`_subline_rules`). В словаре ядра `statement_lines.yaml` (132 строки, редакции 66n/fsbu4) пятизначных кодов нет — и в «упорядоченном перечне кодов по редакциям» их быть не может: они свои у каждой организации. +**Сценарий.** Правило дизайна: «Код подтверждён, если он **есть в перечне** и его положение … совпадает с порядком бланка; … иначе `needs_review` у строки, документ — `needs_check`». Уверенность не спасает: «Подтверждён — уверенность не важна; иначе `needs_review`» — оговорки для `confidence = 100` нет. НПЗ ББ (43 кода на листе 2, большинство пятизначные), НПЗ ОФР, Унагранде ОФР 3кв.25 — сегодня `done` (манифест строки 18, 39, 47–48) — по дизайну `needs_check`; Excel-путь теряет автоподтверждение целиком, у шлюза 1С-подобных сканов тоже. +Второе: «положение относительно соседних **подтверждённых** кодов» — определение циклическое, алгоритм не задан. +**Как закрыть.** (а) Пятизначный код подтверждён, если его четырёхзначный родитель по правилу `subline_parents` (ближайшая строка выше с теми же тремя цифрами, иначе код из первых четырёх цифр) подтверждён и подстрока стоит после него; (б) алгоритм — наибольшая подпоследовательность кодов, согласованная с порядком бланка хотя бы в одной из проверяемых редакций; коды вне её — `needs_review`; (в) явно спросить владельца: подтверждать ли структурой коды с уверенностью 100 (Excel) — по тексту шага 8 п. 1 «Excel выполняет условия уверенности автоматически» это, видимо, не задумывалось. + +### V-3 — important — F-1 ↔ F-8: «форма записи знака» не хранится — ядро не может пересчитать конвенцию после PATCH; «сырой текст в подсказке» для распознанных ячеек пуст +**Где:** «Подтверждение проверкой» (знак), «Что уходит из ядра» (пересчёт `needs_review` общим кодом после PATCH), шаг 4 п. 1–3, шаг 9. +**Что проверил.** Контракт v1.2: `values[].string_value` — `["string","null"]` (схема l.199), DBML `financial_statement_value.string_value` — «как распознано, если не число»; шлюз пишет его только при `amount = null` (`document.py:149-151`). Дизайн (шаг 4 п. 3) расширяет это до «догадки» (`is_recognized = false`), но у ячейки, разобравшейся чисто, `string_value` остаётся `null`. Форма записи (скобки / минус / без знака) из `amount = −7744536` не восстанавливается. +**Сценарий.** aep_3 ОФР, колонка c2: 2120 `(7744536}` 88,9 → покрыто по величине, знак по конвенции (2210 `(693052)` 100, 2220 `(278598)` 100 — скобки) → `needs_review = false`. Аналитик правит любую ячейку документа → PATCH → ядро «пересчитывает `checks` и `needs_review` общим кодом»; входа «форма записи» у ядра нет → либо ветка знака пропускается (подсветка знака исчезает молча — то, против чего F-1), либо «сравнивать не с чем» → `needs_review = true` у всех членов `abs_terms` → `done` недостижим правкой. Второе следствие: 2210 `(546441)` с уверенностью 62,5 в c4 (чистый разбор, `is_recognized = true`) — «сырой текст и уверенность остаются в подсказке ячейки», но сырого текста в результате нет. +**Как закрыть.** В контракт v2.0 — `values[].text` (сырой текст источника всегда, как `Cell.text` шага 1), в DBML v5.19 — `financial_statement_value.source_text`; конвенция и подсказка считаются от него. Альтернатива — `sign_form: brackets | minus | none` + `text` только у `is_recognized = false`, но это два поля вместо одного. + +### V-4 — important — F-16 / решение № 3: сегмент-продолжение без шапки с 1–2 кодами не получает столбца кодов — строка 2400 НПЗ теряется +**Где:** шаг 2 п. 1 (б) («кодов ≥ 3»), п. 2 («без шапки — столбец кодов по правилу п. 1»), решение № 3 («`relaxed` … снят, потому что продолжения склеиваются в шаге 1»). +**Что проверил.** НПЗ, лист «Отчет о финансовых результата_2»: подписи нет, кодов в столбце — **один** (2400); манифест строка 48: «лист с единственной строкой 2400 присоединён (O-9)», документ `done` по 3 листам. Сегодня: `_continuation` → `find_layout(relaxed=True)` (`statement.py:94-105`, `min_codes_continuation: 1`, `statement_headers.yaml:92`), затем `_weak_blocker` (`:145-160`). Синтетика `st07_single_code_continuation.xlsx` — тот же случай. +**Сценарий.** По дизайну лист дописывается в сетку в шаге 1, граница листа открывает сегмент, «у сегмента своя раскладка: … без шапки — столбец кодов по правилу п. 1» → (а) подписи нет, (б) кодов 1 < 3 → столбца нет. Что происходит с сегментом без столбца кодов, не сказано; при любом чтении строка 2400 либо теряется (принцип 2 нарушен), либо весь документ получает `error` «лист без колонки кодов» → `needs_check`. Склейка в шаге 1 этому не помогает — раскладка всё равно своя. +**Как закрыть.** Для сегмента не первого: подписи нет → столбец кодов наследуется от предыдущего сегмента, если в нём хотя бы один код (это и есть сегодняшний `relaxed` + `_weak_blocker` «коды из диапазона формы»); иначе — правило (б); не найден → `error` сегмента. Поправить решение № 3. + +### V-5 — important — F-8 / F-9: `needs_review` строки негде хранить; снятие его назначением кода не описано +**Где:** «Код и наименование строки» (`StatementLine.needs_review`), шаг 8 п. 1 («нет значений **и строк** `needs_review`»), DBML v5.19 (только `financial_statement_value.needs_review`), «Назначение кода строке». +**Сценарий.** Результат с `needs_review = true` у строки 1230 (код с уверенностью 50 не на месте) → ядро «при загрузке ничего не пересчитывает, хранит как пришло» — колонки нет → признак теряется; после PATCH функция `done` получает вход «статусы строк, `needs_review`» неполным. Аналитик назначает код `unreadable`-строке (F-14) — `line_code_modified = true`, но правила «`line_code_modified` ⇒ `needs_review` строки = false» нет (для значений аналогичное `is_modified` ⇒ false записано) → строка подсвечена навсегда. +**Как закрыть.** DBML v5.19: `financial_statement_line.needs_review` (как пришло, пересчёт после PATCH); правило `line_code_modified = true` ⇒ `needs_review = false`; либо явно: признак строки хранится записью `document_issue(kind = warning, line_id)` и критерий `done` читает его оттуда — но тогда так и написать. + +### V-6 — important — F-12: уровень `error` для «ИНН не распознан» и «формат не совпал с `file_type`» противоречит критерию `done` и принципу решения № 1; регрессия НПЗ ОФР +**Где:** шаг 8 п. 1 («(организация или ИНН)»), п. 2 (`error`: «ИНН не распознан», «формат файла не совпал с `file_type`»), решение № 1 («`error` — то, что меняет данные или их полноту»). +**Сценарий.** НПЗ ОФР: в шапке нет ИНН (манифест строка 45 `inn_optional_for: [INCOME]`), организация есть; сегодня `done`. По дизайну критерий 1 проходит (организация есть), но предупреждение «ИНН не распознан» уровня `error` даёт `needs_check` — два правила одного документа спорят, побеждает `error`. Формулировка «не распознан» — из шлюза; что сервис выдаёт у Excel при отсутствующем ИНН, не сказано (сегодня парсер об ИНН не предупреждает). «Формат не совпал с `file_type`»: файл прочитан верно, данные и полнота не изменились — по собственному принципу решения № 1 это `info`; сегодня — предупреждение без влияния на статус. +**Как закрыть.** «ИНН не найден/не распознан» → `error` только если и организации нет (= критерий «организация или ИНН»); при организации — `info`, сверку с ЮЛ делает ядро (`MISMATCH`). «Формат не совпал» → `info`. Таблицу уровней выровнять с принципом решения № 1. + +### V-7 — important — Р-10 / F-11: два документа одного типа и периода у одного ЮЛ — правила выбора нет; половинки формы внутри файла не склеиваются +**Где:** «Что уходит из ядра» (DBML `document`, «привязка к версии консолидации — по периоду, как у ОСВ»), Р-10, шаг 1 п. 6 (OCR: «одна таблица = одна форма, склейки нет — без изменений»). +**Сценарий 1.** Лист-продолжение с маркером другого типа → отдельный кандидат того же периода; или ИС вернула ББ двумя таблицами (актив/пассив) — у ОСВ второй документ различим по счёту, у ББ — нет. Чек-лист `required_document_rule` (индекс `(legal_entity_id, document_type, osv_account_code, period_year, period_quarter, status)`), привязка к консолидации «по периоду», межформенная проверка `_pair_document` (`repositories/statements.py:330-346`: подтверждённый, затем последний по `created_at` — в одном файле `created_at` одинаков) — все берут «один документ на тип и период». Два подтверждённых ББ одного периода → неопределённость в КФО. +**Сценарий 2.** Половинки (актив `needs_check` по `1600 = 1700`, пассив `needs_check`) теперь обе хранятся, но операции «склеить» нет (OQ-140 запрещает только между файлами, внутри файла не описано) — аналитик не может довести файл до `done` иначе как перезагрузкой. +**Как закрыть.** Записать: в чек-лист и консолидацию идёт подтверждённый документ; подтверждение второго документа того же типа и периода у ЮЛ — 409 с указанием первого (или автозамена, как между файлами, §12 «Автозамена», `05-domain-rules.md:689`); склейка половинок не предоставляется — зафиксировать как известное ограничение или вопрос в `07-open-questions.md`. + +### V-8 — minor — F-3 / шаг 2–3: роль строк над строкой заголовка не определена — титульный блок с числами становится `missing` +**Где:** выход шага 2 (`TableLayout`: роли `header | numbering | data`), шаг 3 («по строке на каждую строку с ролью `data`, у которой есть код или хотя бы одно число»). +**Что проверил.** Межениновская Форма1: над заголовком (r23) строки r6 «Дата (число, месяц, год) | 30 | 9 | 2025» и r15 «подлежит обязательному аудиту | 0 | ДА | 1» — кода нет, числа правее столбца кодов; Форма2 — r6. Сегодня они не данные: `find_layout` ставит `first_data_row = header_last_row + 1` (`columns.py:294-310`), `collect_lines` начинается с неё. +**Сценарий.** Если в `TableLayout` эти строки — `data` (третьей роли нет), по шагу 3 они без кода и с числами над первой строкой с кодом → `missing` → `done` недостижим у Межениновской (сегодня `done`, манифест строки 50–56). +**Как закрыть.** Роль `title` (или «не строка») для всего, что выше строки заголовка сегмента; при отсутствии заголовка — выше первой строки с кодом; `missing` — только между заголовком и первой строкой с кодом. + +### V-9 — minor — F-21 ↔ F-25: порядок «разлепить по переводу строки» и «убрать переносы в коде» не задан +**Где:** шаг 2 п. 1 и шаг 3 п. 1 («пробелы и переносы убираются») против шага 3 п. 2 («ячейка кода содержит N частей через перевод строки → N строк»). +**Сценарий.** «1500\n1700» по п. 1 → «15001700» → не код → `unreadable`; по п. 2 → две строки `ok`. Записать явно: сначала п. 2 (разлепление), затем п. 1; для поиска столбца (шаг 2) многострочная ячейка считается кодом, если **каждая** часть — код. + +### V-10 — minor — F-17: `recognition_job.statements_version` не в списке DBML v5.19 +`recognition_job` имеет `service_name`, `service_version`, `result_schema_version`, `metrics jsonb` — отдельной колонки под версию общего кода нет, а «Что уходит из ядра» требует писать её и сравнивать. Добавить `statements_version varchar` в ту же миграцию (или оговорить хранение в `metrics`). + +### V-11 — minor — F-9 ↔ Р-3: состав и порядок кодов по редакциям живут в двух местах +Общий пакет получает «упорядоченный перечень кодов по редакциям», ядро оставляет `statement_lines.yaml` с теми же редакциями и порядком бланка (`editions: 66n` l.9, `fsbu4` l.72) ради наименований и проверки «код не из редакции». Это та же пара копий, которую дизайн устраняет для §7. Предложение: словарь ядра — только `код → наименование` (по редакциям), порядок и состав редакций ядро берёт из своей копии пакета `arm_statements`; `sync-statements --check` сверяет, что все коды словаря ядра есть в перечне пакета. + +### V-12 — minor — F-14: пробелы в операции назначения кода +Не описаны: переход `ok → detail` (ложный код, который аналитик хочет вывести из агрегата, не удаляя строку), недопустимость пары `line_code: null` + `line_status: ok`, пересчёт `parent_row_index` при переходе `missing → detail` (родитель — ближайшая строка `ok` выше), что происходит с `line_code_confidence` после назначения (100?). + +### V-13 — minor — F-1: область правила знака не ограничена сомнительными ячейками; `null`-члены не исключены из большинства +Текст: «не совпадает или сравнивать не с чем — `needs_review = true` независимо от соотношения». При буквальном чтении правило применяется и к чистым ячейкам (`confidence = 100`, `is_recognized = true`): Excel-ОФР, где 2120 в формате `(#,##0)`, а 2410 — `-#,##0`, получит подсветку расходов; OCR-ОФР малой компании, где из членов `abs_terms` заполнена одна 2120 с уверенностью 100 — «сравнивать не с чем» → `needs_review`. Решение № 10 про «вперемешку скобками и минусом — это и нужно» относится к сомнительным ячейкам. Записать: знак проверяется только у ячеек, которым нужно покрытие (`confidence < 100` или `is_recognized = false`); в большинство входят члены с непустым `amount`. + +## 3. Согласованность правок между собой + +- **F-2 ↔ F-1 ↔ F-13 ↔ F-3/Р-8 ↔ Р-4/F-15.** Цепочка «агрегат по коду = Σ строк `ok` → соотношение посчитано, если хотя бы один член непуст → сошлось в пределах допуска или округления → покрывает только ячейки с `amount ≠ null` → для `abs_terms` только величину» внутренне непротиворечива; `detail`/`unreadable`/`missing` исключены из агрегата, подстроки 5xxxx — `ok` и проверяются по AURORA-1527 (мягко, подсветки не дают). Сбоит только хранение формы знака (V-3) и область правила (V-13). +- **F-6 ↔ F-10 ↔ F-9.** Одна «эффективная дата» (решение № 12) питает относительные подписи, редакцию перечня кодов и критерий `done`; пересчёт после PATCH даты описан. Согласовано. Не задан `period_kind` у колонки без подписи (шаг 2 п. 4) — предположительно `none`, стоит записать. +- **Статус `done` ↔ пересчёт после PATCH (F-8).** Входы функции названы; `is_modified`, акцепт, сохранение `error` — есть. Пробелы: `needs_review` строки (V-5), форма знака (V-3). +- **F-9 ↔ Р-3.** Перечень без наименований Р-3 не нарушает; но дублирует редакции/порядок словаря ядра (V-11) и не покрывает подстроки (V-2). +- **Контракт v2.0 (F-7/Р-9) ↔ DBML v5.19 (F-4/F-8/F-11/Р-10).** Соответствие полей: `line_status`, `line_code_text`, `row_index` → `row_index`, `ref` → `source_ref`, `parent_ref` → `parent_row_index`, `values[].needs_review` → `value.needs_review`, `columns[].period_kind` → `column.period_kind`, `document.status` → `document.status`, `document.warnings[]`/`checks` → `document_issue`, `document` без уникальности + порядковый номер, `crm_id`. `line_code` уже nullable в DBML v5.18, `period_date` nullable — миграция по ним не нужна. Не хватает: `financial_statement_line.needs_review` (V-5), `recognition_job.statements_version` (V-10), сырого текста значения (V-3). `service.statements_version` ложится в существующий объект `service {name, version}` контракта — аддитивно. +- **Р-11 ↔ F-1/F-2.** «Двойник ⇒ `is_recognized = false` ⇒ нужно покрытие ⇒ покрытие требует посчитанного соотношения и `amount ≠ null`» — согласовано; `}`/`]` как закрывающая скобка: не сказано, считается ли это «исправлением символа» (влияет только на то, нужно ли покрытие ячейке с уверенностью 100 — у `(7744536}` уверенность 88,9, вопрос не возникает). +- **Решение № 1 ↔ шаг 8 п. 2.** Списки `error` совпадают, кроме `BACKEND_PARSING_ERROR` и «формат не совпал» (есть только в п. 2); принцип решения № 1 нарушен двумя пунктами (V-6). +- **Р-8 ↔ §12 п. 5 / §11.1 п. 4 / строка 688.** Дизайн отменяет OQ-36/OQ-141; OQ-149 («восстановление строк по наименованию») не упомянут и остаётся открытым — противоречия нет, но при правке §12 его надо сохранить. + +## 4. Что проверил и признал согласованным + +- Р-2: ИС отдаёт ячейку на каждую позицию (по 8 таблицам повторно подтверждено, позиций не хватает 0). +- Статусы строк без кода по OCR: aep_3 ББ `detail` 2 / `unreadable` 6 / `missing` 0; aep_3 ОФР 1 / 1 / 1 («Выручка»); aep_6 ББ 19 / 2 / 0, ОФР 2 / 3 / 0; «БО 1 кв 2025» ББ 20 / 3 / 0, ОФР 2 / 2 / 0; «Русский бисквит 1 кв.25» ББ 40 / 3 / 0, ОФР 12 / 2 / 0 — цель Р-7 («чистый скан может стать `done`») по строкам достижима. +- Конвенция знака на данных: aep_3 ОФР c2 — 2120 `(7744536}` 88,9; 2210 `(693052)` 100; 2220 `(278598)` 100; 2330 `{38378)` 71,4; 2350 `(241654)` 75; 2410 `(108450)` 87,5 → конвенция «скобки», 2120 подтверждается, 2330 подсвечивается — как и описано в дизайне. aep_6 c4: 2220 `(124)` 100, 2350 `(Ю4)` 75 — «Ю» не двойник, остаётся грязной и подсвечивается. +- Диапазоны кодов словаря (BALANCE 1100–1799, INCOME 2100–2999, DISCLOSURE 5000–5999; `min_share 0.6`, `min_codes 3`, `min_codes_continuation 1`) — шаг 2 п. 1 (б) воспроизводит парсер. +- Шаг 6 п. 1 ↔ `statement_labels.yaml` шлюза (4 подписи: ББ, ОФР ×3) ↔ `statement_headers.yaml` парсера (ОКУД, «Пояснения» → DISCLOSURE) — слияние словарей не противоречит; форма 4 (aep_8_form4) сегодня `error` от ИС, регрессии нет. +- Шаг 9 / Р-9 ↔ `contract/common.py` (`SCHEMA_VERSION_PATTERN`, `ContractModel extra="ignore", strict=True`) — два маппера реализуемы; запрос остаётся 1.x. +- F-13 ↔ `statement_checks.yaml` (жёсткие 1100…1700, 2100/2200/2300; мягкие 2400 ×2, 2410, 2500; `abs_terms` 2120/2210/2220/2330/2350/2410) — формулировки дизайна совпадают со словарём. +- F-18 ↔ `form_confirmable` (`services/statements.py:71-81`: квартал обязателен) — `period_known` в ядре сохранён. +- F-6 ↔ `statement_period` (`statement_mapping.py:73-80`), F-9 ↔ `edition_for` (`:83-87`, граница 2024-12-31) — одна формула эффективной даты и редакции. +- F-15: `_pair_document` уже имеет порядок выбора (подтверждённый → последний), агрегат по коду его не ломает; `line_origin` (`schemas/statements.py:157-159`) по `line_code_original_value` при появлении `line_status` можно заменить, но это не противоречие. +- Уровень «xlsb без форматов», «дробные при тыс.», «подпись из словаря исключений» = `info` — совпадает с сегодняшним поведением парсера (документ `done`). + +## Файлы и символы, которые смотрел + +**docs** (worktree `.wt/normalization/docs`): `openspec/changes/unified-statement-normalization/design.md` (целиком), `review-2026-10-17.md` (целиком), `context/05-domain-rules.md` (строки 505–530 §7, 686–690, 990–999 §11.1 п. 4, 1105–1115 §12 п. 2, 1137–1155 §12 п. 5/7), `contracts/recognition-result.schema.json` (`$comment`, `service`, `lines[]`, `values[]`, `checks`, `string_value`), `model/data-model-v5.18.dbml` (`document`, `recognition_job`, `financial_statement_meta/column/line/value`). +**tabular-parser**: `api/src/parser/statements/columns.py` (`_CODE`, `line_code` 63–71, `find_code_column` 74–95, `_first_code_row`, `_find_header_row` 127–146, `_value_columns` 229–238, `find_layout` 294–310), `statement.py` (`_block_draft` хвост, `_continuation` 94–135, `_sheet_codes`, `_weak_blocker` 145–160, `_fill`), `detect.py` (`is_weak_continuation` 131–134, `classify_statement_sheet`, `_Candidate`), `api/rules/statement_headers.yaml` (19–34, 78–96), `statement_checks.yaml` (6–28), `api/tests/statements_manifest.yaml` (1–75). +**scan-gateway**: `api/src/normalizer/ocr/amounts.py` (`parse_amount` 44–84, `is_blank`), `rows.py` (`classify_row` 58–71, `split_merged_row` 92–118, `_data_rows`), `document.py` (`_value` 134–159), `api/rules/statement_labels.yaml`, `api/src/gateway/merge.py` (контекст склейки). +**arm-core**: `api/src/domain/statement_checks.py` (`subline_parents` 141–162, `_subline_rules` 165–184, `run_checks` 187–197), `statement_mapping.py` (`statement_period` 73–80, `edition_for` 83–87), `contract/common.py` (30–70), `services/statements.py` (`form_confirmable` 71–81), `repositories/statements.py` (`_pair_document` 330–346), `schemas/statements.py` (`line_origin` 157–159), `api/rules/statement_lines.yaml` (структура редакций, отсутствие пятизначных кодов), `services/result_mapping.py` (точки `MappingFatal`). +**fixtures** (скриптами `python3 -I` и venv парсера с `-I`): `ocr/aep_3.json`, `aep_3_ofr.json`, `aep_5.json`, `aep_6.json`, `aep_8_form4.json`, `БО 1 кв 2025.json`, `Бухгалтерская отчетность 1 кв.25.json`, `ocr/expected_normalize/*.json` + README; `statements-xlsx/unagrande-co/2024-FY/Б.Б Лист 1.xls`, `Б.Б. Лист 2.xls`, `ОФР. Лист 1.xls`, `unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls`, `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx`, `1_Форма2_бух_09.xlsx`, `npz/2025-Q3/Бухгалтерская отчетность за Январь - Сентябрь 2025 г. (…).xlsx` (все 9 листов), `synthetic/st07_single_code_continuation.xlsx`. + +Не смотрел: arm-web (не индексирован, по заданию), `services/documents.py`, `auto_confirm.py`, чек-лист (`repositories/consolidations.py`) — следствия Р-10 для них описаны по индексу DBML, а не по коду. diff --git a/openspec/changes/unified-statement-normalization/review-2026-10-17.md b/openspec/changes/unified-statement-normalization/review-2026-10-17.md new file mode 100644 index 0000000..82088a9 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/review-2026-10-17.md @@ -0,0 +1,212 @@ +# Независимое ревью design.md — 17.10.2026 + +Ревьюер: отдельный агент (модель Fable), без права правки. Задание: дыры в целевой картине — противоречия, разрывы +цепочки, необработанные входы, регрессии, граничные случаи `needs_review`/`done`, следствия для ядра и БД, +сопровождаемость Р-1, пропущенные различия. Решения Р-1…Р-7 не пересматривались. + +Сверка перед передачей владельцу (сессия): подтверждены по коду и данным F-1 (`abs_terms` в +`tabular-parser/api/rules/statement_checks.yaml:22-25`; golden `fixtures/ocr/expected_normalize/aep_3.json` — +2120 = +7744536, `is_recognized=false`), F-2 (`null` → 0: `arm-core/.../statement_checks.py:202`, +`tabular-parser/.../checks.py:27-34`), F-3 (OQ-36/OQ-141 в §12 п. 5, строка 688 и 1137 `05-domain-rules.md`), +F-5 (`statement_headers.yaml:39` `code_caption: '^код(ы)?$'`), F-6 (поле `date` у ИС: aep_3 «2|0|2|4|», aep_6 и +«БО 1 кв 2025» — название организации), F-7 (`$comment` схемы: смена типа = MAJOR), F-19 (§11.1 п. 1, строка 967). +Остальные находки — по тексту агента, выборочно не перепроверялись. + +Отчёт агента ниже без сокращений. + +--- + +# Ревью дизайна `unified-statement-normalization/design.md` + +Проверено: документ целиком; §7, §11, §11.1, §12 `05-domain-rules.md`; контракт v1.2 (`$defs.statementDocument`, `failedRelation`, `money`, корень с `$comment` о версионировании); DBML v5.18 (`document`, `financial_statement_meta/column/line/value`, `recognition_job`, enum `document_status`); код scan-gateway (`normalizer/ocr/*`, `rules.py`, `contract/*`), tabular-parser (`parser/statements/*`, `book.py`, `grid.py`, `numbers.py`, `rules.py`, `api/rules/*.yaml`, `tests/statements_manifest.yaml`), arm-core (`domain/statement_mapping.py`, `domain/statement_checks.py`, `domain/statement_lines.py`, `services/result_mapping.py`, `services/statements.py`, `services/auto_confirm.py`, `services/documents.py`, `repositories/statements.py`, `schemas/statements.py`, `models/statements.py`, `contract/*`, фронт `DocumentReview/statementGrid.ts`, `StatementTable.tsx`, `ChecksPanel.tsx`, `formLayout.ts`, `types/api.ts`); фикстуры `fixtures/ocr/*.json` и golden-эталоны `fixtures/ocr/expected_normalize/*` (скриптом); `docs/scripts/sync-web.sh`, `03-architecture.md` (политика версий контракта, риск копий словарей). + +Числа из фикстур, на которые опираются находки: +- Позиций ячеек под колонками не хватает: 0 из 2224 (8 таблиц, 4 json) — предпосылка Р-2 верна. +- Уверенность ячеек **сумм** (не кодов и не наименований): 100 — 1109, 70–89 — 54, 1–69 — 75, 0 — 19, 90–99 — **1** (`aep_3` ОФР, «2100 I 150416», 92,3) — предпосылка Р-5 по сути верна. +- Строк «код пуст, в значениях есть число» (кандидаты в `missing`): aep_3 ББ 1 / ОФР 2; aep_6 ББ 18 / ОФР 2; «БО 1 кв 2025» ББ 20 / ОФР 2; «Русский бисквит 1 кв.25» ББ **40** / ОФР 12 — это строки «в том числе: …», «Оборудование к установке», «Товар (41.01)». +- Строк с кодом не из 4–5 цифр (`unreadable`): aep_3 ББ 8 («НИ», «изо :», «| 140», «[190 :», «1 100», «1220 ,», «12 /», «154л)», «420»), ОФР 1 («2.10», «24 12»); в чистых сканах 2–4 на таблицу. +- Доля кодов в столбце 1 по правилу парсера (4–5 цифр в диапазоне, порог 0,6): aep_3 ББ 0,78 (31 код), ОФР 0,87 — порог запасного прохода aep_3 не теряет. +- В aep_3 ОФР коды 2110 и 2100 стоят в колонке **значений** («211(1 |», «2100 I 150416»), ячейка кода пуста. +- Golden шлюза по aep_3 сегодня: ББ 28 строк (в т. ч. код «420» с уверенностью 99), ОФР 13 строк, 2120 = **+7744536** (`is_recognized=false`), статус `needs_check`. + +--- + +## Critical + +### F-1 — critical — «Подтверждение проверкой», Р-7 + `abs_terms`: проверка не видит знак, а Р-7 снимает подсветку → ошибка знака автоподтверждается +**Где:** шаг 8 «Подтверждение проверкой» (абзац про «(7744536}»), Р-7, шаг 7 п. 1. +**Дыра.** Соотношения ОФР считаются дважды — «как записано» и с модулем членов `abs_terms` (`tabular-parser/api/src/parser/statements/checks.py:57-74`, `arm-core/api/src/domain/statement_checks.py:127-138`, словарь: `2100 = 2110 - 2120, abs_terms: [2120]`, `2200 … abs_terms [2210, 2220]`, `2300 … [2330, 2350]`, `2400 … [2410]`). Для члена из `abs_terms` соотношение **сходится при любом знаке** значения: 2110 − (+7744536) и 2110 − |−7744536| дают одно и то же. Значит, сошедшееся жёсткое соотношение не может подтвердить знак такой ячейки, а Р-7 именно по сошедшемуся соотношению снимает подсветку с `is_recognized = false`. +**Сценарий.** ОФР: 2110 = 8 096 738 (100), 2120 = «(7744536}» — закрывающая скобка `}` минус не ставит (§11.1 п. 7, `amounts.py:58-80`; golden aep_3 — `+7744536`, `is_recognized=false`), 2100 = 352 202 (100). `2100 = 2110 − 2120` «как записано»: 8 096 738 − 7 744 536 = 352 202 — сошлось. По Р-7 ячейка 2120 покрыта → `needs_review = false`, подсветки нет, документ может стать `done` и автоподтвердиться с **положительной** себестоимостью. Проверка 2200 при этом ничего не ловит: `abs` применяется к каждому соотношению независимо, смесь знаков в документе проходит. В консолидацию уходит ошибка знака, которую сегодня видно по `is_recognized=false`. +**Текст дизайна прямо утверждает обратное:** «„(7744536}“ → −7 744 536 при 2100 = 2110 − 2120 верна» — при текущем `parse_amount` результат +7 744 536, и соотношение верно для обоих знаков. +**Как закрыть.** Либо (а) Р-7 не распространяется на ячейки-члены `abs_terms`, когда соотношение сошлось только вариантом `abs` или когда знак ячейки отличается от знака остальных членов `abs_terms` в колонке (одна конвенция знаков на документ: все расходы либо в скобках, либо с минусом); либо (б) `parse_amount` принимает `}`/`]` как закрывающую скобку (OQ-147), и покрытие считается только по варианту «как записано» с единой конвенцией знаков на колонку. В любом случае спека должна зафиксировать, что проверка §7 с `abs_terms` знак не подтверждает. + +### F-2 — critical — «Подтверждение проверкой»: тривиально сошедшееся соотношение (все члены `null` → 0 = 0) «покрывает» нечитаемые ячейки +**Где:** шаг 8 «Подтверждение проверкой», первый пункт («значение не покрыто сошедшимся жёстким соотношением»). +**Дыра.** В обеих реализациях `null` в соотношении = 0 и соотношение проверяется, если строка-цель есть в документе (`statement_checks.py:200-206`: `amounts = {code: values.get(col) or 0}`, пропуск только при отсутствии строки; парсер `checks.py:27-34`). Соотношение, у которого в колонке все члены `null` (включая цель), сходится как 0 = 0. По дизайну такое «сошедшееся жёсткое соотношение» подтверждает все свои ячейки независимо от `confidence` и `is_recognized`. +**Сценарий.** Правая (третья) колонка скана обрезана или залита полосой: все ячейки раздела IV — мусор (`amount = null`, `is_recognized = false`, уверенность 0–40; в aep_3 такие ячейки есть: 1430 «! : г ’ .» 20, 1450 «# -» 33, 1540 «<» 0). `1400 = 1410 + 1420 + 1430 + 1450` → 0 = 0 → сошлось → все четыре ячейки `needs_review = false`. Если так по всей колонке (`1600 = 1100 + 1200` → 0 = 0), колонка из `null` не подсвечивается, не мешает `done` и автоподтверждается — тихая потеря периода, ровно то, что Р-6 запрещает для колонок без даты. Сегодня это невозможно: `values_ok` требует `is_recognized` у каждой ячейки (`statement_mapping.py:211-218`). +**Как закрыть.** Соотношение считается подтверждающим только если хотя бы один его член в колонке непуст (`amount` не `null`) — то же правило, что уже принято для «родитель = Σ подстрок» (AURORA-1527, `run_checks` 207-210); ячейка с `is_recognized = false` и `string_value` не пустым (мусор, а не прочерк) не считается покрытой, если соотношение сошлось за счёт её `null`. + +### F-3 — critical — шаг 3 п. 4 / шаг 8 п. 1: строки `missing` делают `done` недостижимым для реальных форм; отменяет OQ-36/OQ-141 без упоминания; нет действия аналитика +**Где:** шаг 3 (выход `RawLine[]`, `missing — кода нет, суммы есть`), шаг 3 п. 4, шаг 8 п. 1 («все строки `ok`»), Р-5 («иначе `needs_review` у строки, документ — `needs_check`»). +**Дыра.** Строки «в том числе: …» и прочие расшифровки под строками формы **законно** не имеют кода. Сегодня они не отдаются ни одним сервисом по решениям OQ-36/OQ-141 (§12 п. 5: «Строки без кода (разделы, «в том числе:», «Реализация иного имущества…» у НПЗ) не отдаются»; §11.1 п. 4). Дизайн превращает их в строки `missing`, требует для `done` «все строки `ok`» и не даёт аналитику способа сказать «это расшифровка без кода, так и должно быть». Изменение решения OQ-36/141 нигде не зафиксировано (ни в тексте, ни в «Decisions made without discussion»). +**Сценарий.** «Русский бисквит 1 кв.25» (чистый скан: ИНН 100, организация 100, дата «за январь - март 2025 г.» разбирается, коды почти все 100): 40 строк без кода с суммами в ББ и 12 в ОФР → 52 строки `missing` → документ никогда не `done`, цель Р-7 («такой документ может получить `done` и автоподтвердиться») недостижима ни для одной из четырёх OCR-фикстур. Для Excel — регрессия: книга НПЗ (`fixtures/statements-xlsx/npz`, манифест строки 47–48, сегодня `done` по 5 и 3 листам) содержит строки без кода с суммами (§12 п. 5) → `needs_check`. +**Как закрыть.** Разделить «строка без кода» на два случая: расшифровка под строкой с кодом (наименование начинается с «в том числе»/стоит под родителем — у OCR и Excel это определимо по позиции и тексту) — статус `detail`, в `done` и проверки не входит, хранится ради показа; и «строка формы без кода» (наименование совпадает со строкой формы по словарю ядра либо суммы не укладываются под родителя) — `missing`. Плюс действие аналитика на экране: «назначить код» / «это расшифровка» (см. F-14). И явно записать отмену OQ-36/OQ-141 в раздел решений. + +### F-4 — critical — шаги 3/5/8, «Что уходит из ядра»: порядок строк и `ref` не хранятся — строки `missing`/`unreadable` и предупреждения с `line_ref` теряют место после маппинга +**Где:** шаг 3 (`ref`), шаг 5 («порядок как в файле»), шаг 8 п. 5 (`line_ref`, «экран показывает у места»), «Что уходит из ядра» («Экран показывает строки как в файле», «строки `missing`/`unreadable` показываются как пришли»). +**Дыра.** `financial_statement_line` (DBML v5.18) не имеет ни `row_index`, ни `ref`; ядро читает строки `ORDER BY line_code, id` (`repositories/statements.py:289`), ответ сортирует по коду (`schemas/statements.py:221`: `(line_code is None, line_code or "")`), фронт раскладывает по сотням кода, а строки без 4-значного кода уносит в «Прочие строки» в конец (`formLayout.ts:107-111`, `incomeLayout` 122). Дизайн обещает DBML v5.19 только для снятия уникального индекса. +**Сценарий.** aep_6 ББ: 18 строк `missing` («в том числе: Сырьё и материалы 35 465», «Готовая продукция 2 827», «Расчёты с поставщиками 29 526» …) и 4 `unreadable`. После маппинга все 22 строки — в конце формы, без привязки к 1210/1230/1520; аналитик не может понять, какой код назначать и куда строка относится. Предупреждение шага 8 с `line_ref` (OCR `row_index` / Excel A1) не к чему привязать: у сохранённой строки нет `ref`. Обещание «пользователь видит причины статуса на экране у места» после сохранения не выполняется. +**Как закрыть.** DBML v5.19: `financial_statement_line.row_index smallint` (порядок в документе, одинаково для OCR и Excel) и `source_ref varchar` (локатор ячейки кода), контракт — `lines[].ref`; `load_form`/`statement_out` — порядок по `row_index`; `formLayout` — строки без кода выводить под предыдущей строкой с кодом, а не в конец. Это же даёт родителя для «родитель = Σ подстрок» (сегодня `subline_parents` опирается на лексикографический порядок кодов, `statement_checks.py:147-148`). + +### F-5 — critical — шаг 2 п. 1, проход (1) по заголовку «Код» без порога: в выгрузках 1С подпись «Коды» титульного блока даст ложный столбец кодов; молча исчезает поиск заголовка по периодам +**Где:** шаг 2 п. 1 (два прохода), шаг 2 п. 4, решение № 3. +**Дыра.** Словарь `statement_headers.yaml: code_caption: '^код(ы)?$'` совпадает с «Коды» — подписью мини-таблицы реквизитов в правом верхнем углу формы 66н («Форма по ОКУД 0710001», «Дата (число, месяц, год)», «по ОКПО», «ИНН», «по ОКЕИ 384»). Парсер сегодня защищён порядком: сначала столбец кодов по подсчёту (`columns.py:74-95`, порог), потом заголовок — только подпись **в этом столбце** и только последняя над первым кодом (`_find_header_row` 130-136). Дизайн разворачивает порядок: «есть строка заголовка с ячейкой «Код» → столбец кодов под ней, без порога». В титульном блоке под «Коды» стоят «0710001», «31 12 2024», «12345678», «3528052718», «384» — ни один не код; при выборе этого столбца каждая строка таблицы становится `unreadable`/`missing`, настоящие коды уходят в «наименование»/«значения». +Второе: сегодня заголовок без подписи «Код» находится по «две и более ячейки-периода правее кодов» (`_find_header_row` 137-142) — шаблон банка и самодельные таблицы с «На 31.12.2024 | На 31.12.2023» без слова «Код». Дизайн такой ветки не содержит: это становится «строка заголовка не найдена» → `error` → `needs_check`, колонки без дат. +**Сценарий.** Любая выгрузка 1С «Регламентированный отчёт» (все `unagrande-*`, `mezheninovskaya`, `npz`): первая ячейка «Коды» на листе — титульный блок. Результат: документ из 50 строк `unreadable`, статус `needs_check`, а по дизайну это «так шлюз сейчас». +**Как закрыть.** Проход (1) принимает подпись «Код» только если в столбце под ней есть хотя бы один код из диапазона, и берёт последнюю такую подпись над первой строкой с кодом; либо сохранить порядок парсера (подсчёт → подпись → заголовок по периодам). Ветку «заголовок по двум периодам» перенести в общее правило явно. + +--- + +## Important + +### F-6 — important — шаг 8 п. 1 требует «дата отчёта», шаг 2 п. 6 и ядро живут периодом «из даты отчёта, без неё — из колонок»: OCR-документы без разобранного `date` никогда не `done` +**Где:** шаг 8 п. 1 (`done` ⇔ … ∧ дата отчёта ∧ …), шаг 2 п. 6 («период формы берётся из даты отчёта, без неё — из самой поздней даты остальных колонок»), «Что уходит из ядра» (`statement_status`). +**Дыра.** У ИС поле `date` в 3 из 4 успешных фикстур — не дата: aep_6 и «БО 1 кв 2025» — «Общество с ограниченной ответственностью…», aep_3 — «2|0|2|4|». Колонки при этом разбираются («На 31 марта 2025 г.» → 2025-03-31). Ядро определяет период именно так (`statement_mapping.py:73-80`). Критерий `done` по «дате отчёта» делает «БО 1 кв 2025» (ИНН 100, организация 100, колонки с датами) вечным `needs_check` при любом качестве цифр — и обесценивает Р-7. +**Как закрыть.** В критерии `done`: «период определён» = дата отчёта **или** самая поздняя дата колонок (как `statement_period`); отсутствие даты отчёта при наличии дат колонок — `info`. Относительные подписи (Р-6) считать от этой же «эффективной даты». + +### F-7 — important — шаг 9: v1.3 не MINOR по собственным правилам контракта; порядок раскатки не задан → при обновлении сервиса раньше ядра файлы уходят в `ERROR` +**Где:** шаг 9, «Что уходит из ядра». +**Дыра.** `$comment` схемы: MINOR — только необязательные поля и новые значения enum; MAJOR — удаление/переименование полей, смена типов или семантики; «новый MINOR раскатывается сначала на потребителей». Дизайн в v1.3 делает `line_code` nullable (смена типа), `metrics.warnings[]` и `document.warnings[]` объектами вместо строк (смена типа), убирает `duplicate_line_codes` (удаление), `checks` становится обязательным (семантика). В ядре `StatementLine.line_code: str` (`arm-core/api/src/contract/result.py:161`), `Metrics.warnings: list[str]`, модели `strict=True` (`contract/common.py:51`); незнакомые поля игнорируются (`extra="ignore"`), но `null` в `line_code` и объект в `warnings` — `ValidationError` → `MappingFatal` «результат не соответствует схеме» (`result_mapping.py:195`) → файл `ERROR`. +**Сценарий.** Выкладка контура: tabular-parser обновлён, arm-core ещё нет (разные PR, разные репозитории); первая же Excel-книга со строкой `unreadable` → `ERROR`. +**Как закрыть.** Либо v2.0 с поднятием `SUPPORTED_MAJOR` и двойным приёмом в ядре на время перехода, либо аддитивный v1.3: `line_code` остаётся строкой (для `unreadable`/`missing` — пустая строка, а статус и сырой текст — в новых полях), `metrics.warnings` остаётся строками, структурные предупреждения — новое поле `issues[]`, `duplicate_line_codes` остаётся всегда пустым. И в любом варианте — явный порядок: сначала ядро. + +### F-8 — important — ядро/БД: `needs_review`, `line_status`, `line_code_text`, уровень и привязка предупреждений, `checks` из результата — негде хранить; пересчёт после PATCH не воспроизводит `done` +**Где:** шаг 8 («после PATCH ядро пересчитывает `checks` и `needs_review` общим кодом»), «Что уходит из ядра» (§7 при загрузке, `statement_status`, экран), Р-4 (DBML v5.19 только про индекс). +**Дыра.** +1. В DBML v5.18 `needs_review` сознательно убран (v5.13, заметка у `financial_statement_value.confidence`: «считается, не хранится»), `financial_statement_meta` — «проверки не хранятся», у `financial_statement_line` нет `line_status`/`line_code_text` (есть `line_code_original_value`, которое ядро использует как признак «строка из источника», `schemas/statements.py:157-159`, `repositories/statements.py:131,155-159`). Структурные предупреждения документа хранить негде (`document_out` отдаёт предупреждения маппинга файла, `services/documents.py:135`). Дизайн ни одного из этих полей в DBML не добавляет. +2. Критерий `done` включает «нет предупреждений уровня `error`». После PATCH `recompute_status` передаёт `warnings=()` (`services/statements.py:152-168`, задокументировано как ограничение) — ядро не знает, были ли у документа `error`-предупреждения (колонка без даты, сегменты не совпали) → правка одной ячейки может перевести документ в `RECOGNIZED` при сохраняющейся структурной проблеме. +3. Общий код считает `needs_review` из `confidence`/`is_recognized`/§7, но ничего не говорит про `is_modified`. Сегодня правка аналитика снимает сомнение (`values_ok`: `modified or …`, `statement_mapping.py:211-218`). Без этого исправленная ячейка строки, не входящей ни в одно соотношение (2460, 2900, 29xx, шапка, расшифровки), останется `needs_review` навсегда → `done` недостижим правкой. +4. Акцепт нарушений (`checks_accepted_*`) в критерии `done` не упомянут: после акцепта ядро должно считать жёсткие нарушения снятыми (сегодня `statement_hard_failures` так и делает) — общий код должен принимать это параметром. +**Как закрыть.** DBML v5.19: `financial_statement_line.line_status`, `line_code_text` (или переопределить `line_code_original_value`), `row_index` (F-4); `financial_statement_value.needs_review` (хранить результат сервиса и пересчитывать ядром после PATCH) либо явное решение «считается на каждом GET общим кодом»; таблица `document_issue` (код, уровень, сообщение, `line_id`/`column_id`) или `jsonb` на `financial_statement_meta`. Правило: `is_modified = true` ⇒ `needs_review = false`; акцепт ⇒ жёсткие нарушения не считаются. Критерий `done` реализовать одной функцией общего кода с входами «статусы строк, needs_review, предупреждения `error`, акцепт». + +### F-9 — important — Р-5 «код стоит по возрастанию внутри раздела» ложно для итоговых строк и требует порядок формы по редакции — противоречит шагу 5 п. 2 («редакция в сервисах не определяется») +**Где:** Р-5, абзац «Код и наименование строки», шаг 5 п. 2–3. +**Дыра.** В формах 66н/ФСБУ 4 итоги стоят **после** слагаемых: 1110…1190, **1100**, 1210…1260, **1200**, **1600**; в ОФР 2110, 2120, **2100**, 2210, 2220, **2200** (так и в словаре ядра `arm-core/api/rules/statement_lines.yaml`). «По возрастанию» нарушает каждая итоговая строка → по Р-5 все 1100/1200/1300/1400/1500/1600/1700/2100/2200/2300/2400 получают `needs_review` строки → документ всегда `needs_check`. Правильный порядок — порядок бланка, а он различается по редакциям (fsbu4: 1215, 2411/2412, 2460, иные составы) — то есть сервису нужен перечень кодов **по редакции** и дата для её выбора, что дизайн относит только к ядру (шаг 5 п. 2, Р-3). При Р-4 равные соседние коды тоже надо допустить явно. +**Как закрыть.** «Перечень кодов форм» в общем коде сделать упорядоченным списком по форме и редакции (без наименований, Р-3 не нарушается); редакцию сервис выбирает по эффективной дате (F-6) с запасным вариантом «обе редакции» — подтверждён код, стоящий на своём месте хотя бы в одной; равные соседние коды — допустимы. + +### F-10 — important — Р-6 относительные подписи: после правки даты отчёта в ядре `period_end` колонок не пересчитывается +**Где:** шаг 2 п. 3, п. 6, Р-6; «Что уходит из ядра» (пересчёт после PATCH — только `checks`/`needs_review`). +**Дыра.** Для бланка без впечатанных дат («На отчётную дату», «На 31 декабря предыдущего года») сервис при отсутствии даты отчёта отдаёт `period_date = null`. Аналитик вводит дату отчёта через PATCH шапки; `_apply_header` (`services/statements.py:258-266`) пересчитывает период документа из `meta.report_date` и **сохранённых** `column.period_date`, но сами колонки не трогает и словарь периодов не знает. Колонки остаются без дат: `is_current` `null`, `pair_amounts(current=True)`/`period_date` (`repositories/statements.py:306-327`) ничего не находят → межформенная проверка молча не считается, гудвилл «на отчётную дату» (DBML, заметка у `financial_statement_column.period_date`) не находит колонку. +**Как закрыть.** Либо сервис отдаёт у колонки признак «подпись относительная» + её вид (`relative: report_date | prev_year_end | prev_prev_year_end`), а ядро после PATCH даты считает `period_date` общим кодом; либо PATCH шапки в ядре прогоняет `label` колонок через общий словарь периодов с новой датой. + +### F-11 — important — шаг 1 п. 6 и допущение владельца о нескольких json: несколько кандидатов одного типа на файл, а `document` уникален по `(stored_file_id, document_type)` — вторая форма молча пропадает +**Где:** шаг 1 п. 6 («расхождение — отдельный кандидат + предупреждение»), шаг 1.1 (несколько json на файл), «Что уходит из ядра». +**Дыра.** DBML `document`: `(stored_file_id, document_type, osv_account_code) [unique, NULLS NOT DISTINCT]` — один ББ и один ОФР на файл. Ядро второй документ того же типа пропускает с предупреждением «вторая форма … пропущена (OQ-77)» и ставит первому `NEEDS_CHECK` (`result_mapping.py:363-366`). Дизайн порождает такие пары штатно: лист-продолжение с маркером другого типа → отдельный кандидат; PDF с двумя периодами → два json → два ББ. Сервис считает данные доставленными, ядро их теряет; в «Что уходит из ядра» это не упомянуто. +**Как закрыть.** Записать явно: либо остаётся OQ-77 (потеря + `needs_check`), либо DBML v5.19 снимает уникальность по типу и вводит порядковый номер формы в файле; в обоих случаях — предупреждение `error` у первого документа с указанием `locator` потерянного. + +### F-12 — important — шаг 8 п. 2 / решение № 1: таблица уровней неполная, у нескольких существующих блокираторов `done` уровень не задан → регрессии решений № 28 и «лист потерян» +**Где:** шаг 8 п. 2, «Decisions made without discussion» № 1. +**Дыра.** Сегодня статус `done` блокируют: ошибочный json набора при успешном соседе (`ocr/__init__.py:61-66`, решение № 28: потерянный ОФР не должен остаться под `done`); `BACKEND_PARSING_ERROR` таблицы (§11.1 п. 9, `__init__.py:92-95`); «нет шапки и столбца кода» таблицы (`__init__.py:96-99`); у парсера — потерянный лист-продолжение без колонки кодов (`statement.py:96-105`, `parse_sheets` `dropped`). В перечне уровней их нет; по умолчанию дизайн относит к `info` всё, что «не меняет данные или их полноту», и эти случаи под формулировку не попадают однозначно. Также не задан уровень для «ИНН не распознан / неуверенно», «дата отчёта не разобрана», «формат чисел xlsb не читается» (после tp#42 значения в рублях при единицах «тыс.» — сегодня документ `done`, и дизайн это сохраняет). +**Как закрыть.** Полная таблица уровней в спеке, как минимум `error`: ошибка json набора (файл), `BACKEND_PARSING_ERROR`, таблица/лист без колонки кодов при непустом содержимом, колонки сегмента не совпали, колонка без даты, строка заголовка не найдена, тип по кодам противоречит заголовку; и явное решение по xlsb/дробным. + +### F-13 — important — «Подтверждение проверкой», второй пункт: «не сошлось» не определено для округления (±0,001 %) и мягких соотношений → NPZ и Унагранде из `done` уходят в `needs_check` +**Где:** шаг 8 «Подтверждение проверкой» (пункт «`true` у всех ячеек соотношения в колонке, где оно не сошлось»), шаг 7. +**Дыра.** Жёсткое соотношение с расхождением больше ±1, но в пределах 0,001 %, уходит в `soft_relations_failed` (`checks.py:131-137`, `statement_checks.py:215-220`) и статус не трогает (OQ-138). Мягкие соотношения (2400, 2500, подстроки) тоже попадают в `soft`. Формулировка «соотношение не сошлось» без уточнения охватывает оба случая → все их ячейки `needs_review = true` → по критерию `done` («нет значений `needs_review`») документ `needs_check`. +**Сценарий.** Манифест парсера: НПЗ ББ (`1600 = 1700` расходится на 1,94/9,02/2,48 при ~17 млрд, строка 47) и ОФР (48), Унагранде ОФР (мягко 2400 и 2410 = 2411 + 2412, строки 18, 39) — сегодня `done`, по дизайну `needs_check`; Excel-путь теряет автоподтверждение. +**Как закрыть.** «Не сошлось» = жёсткое нарушение вне допуска и вне округления; соотношение в пределах округления считается сошедшимся для покрытия и не подсвечивает; мягкие соотношения на `needs_review` не влияют (только предупреждение `info`). + +### F-14 — important — шаг 3 п. 1 / п. 4, AURORA-1462: «аналитик назначает 1250» — в API и UI нет операции назначения кода строке +**Где:** шаг 3 п. 1, шаг 5, «Что уходит из ядра» (достройка). +**Дыра.** `StatementPatch` — только `header` и `values` (`schemas/statements.py:298-308`, `types/api.ts:175-178`); `CellPatch.line_code` адресует **шаблонную** строку и отвергается, если код уже есть (`_resolve_cells`, `services/statements.py:216-246`, `LINE_EXISTS`). Поля `line_code_modified`/`line_code_original_value` в DBML есть, но операции нет. Переход `line_status: unreadable → ok` после назначения, аудит (`RECOGNIZED_DATA_EDIT` с `before/after` кода), пересчёт проверок и `needs_review` строки не описаны; что делать с `missing` строкой, которая и должна быть без кода (F-3), — тоже. +**Как закрыть.** В спеку ядра: `PATCH …/statement` с `lines: [{line_id, line_code | null, detail: bool}]`, правила: назначенный код может совпадать с существующим (Р-4), `line_status` → `ok` (или `detail`), аудит, пересчёт; на фронте — поле кода у строк `unreadable`/`missing`. + +### F-15 — important — Р-4 в ядре: кроме индекса, по уникальности кода живут чтения «одна строка на код» +**Где:** Р-4, «Что уходит из ядра» (повторы кодов). +**Дыра.** `_line_amount` берёт `LIMIT 1` по коду (`repositories/statements.py:306-327`) — межформенная `1370 − 1370 = 2400` при дубле 1370/2400 возьмёт одну из строк, не сумму; `statement_amounts` кладёт значение по ключу кода с перезаписью (`:154-159`) — последняя строка побеждает, не сумма; `subline_parents`/`parent_code` при повторном четырёхзначном родителе относит подстроки к последнему по порядку (`statement_checks.py:141-162`); `formLayout` выводит детей один раз под первым родителем (`formLayout.ts:149`); `_resolve_cells` `codes = {line.line_code}` включает `None` (F-14). `metrics.fields_*` считаются по значениям — семантика при `needs_review` меняется. +**Как закрыть.** Перечислить в «Что уходит из ядра» все точки агрегации по коду и ввести одну функцию «агрегат формы по коду» (сумма по колонке, список `line_id`), которой пользуются проверки, межформенная проверка, подтверждение, КФО и выгрузки. + +### F-16 — important — шаг 1 п. 6 (Excel): склейка листов в одну сетку до раскладки ломает продолжения без строки заголовка — позиции столбцов на листах разные, сегмент открывается только шапкой +**Где:** шаг 1 п. 6, шаг 2 п. 2 (сегменты «по признаку шапки»), «проверка числа колонок уходит». +**Дыра.** Сегодня продолжение получает **свою** раскладку (`find_layout` на листе, `statement.py:94-139`), для продолжений без заголовка колонки значений находятся по числам в строках с кодами (`columns.py:229-238`), и присоединяются **строки** контракта, а не ячейки. Такие продолжения есть в фикстурах (манифест: «страница 2 без шапки», строки 13, 20, 26, 28, 34, 41). Дизайн дописывает сырые строки второго листа в конец сетки первого; сегмент с новой раскладкой открывается только строкой с признаком шапки. Если на листе 2 нет шапки (есть только «Форма 0710001 с.2» и строка нумерации), а столбец кодов/сумм стоит в другой физической колонке (другая ширина колонки A, объединённые ячейки), значения читаются по позициям первого листа → суммы попадают не в те колонки или в `null`, без предупреждения (число колонок больше не сверяется). +**Как закрыть.** Граница листа — всегда новая граница сегмента (сегмент = лист или участок между шапками); раскладка сегмента без шапки — по числам (как сегодня), колонки сопоставляются с первым сегментом по порядку, несовпадение числа — `error`. Это сохраняет сегодняшнее поведение и даёт то же для OCR. + +### F-17 — important — Р-1 (копия общего кода скриптом): нет версии копии в результате, «`make check` проверяет свежесть» повторяет ошибку #33, импорты всё же меняются, тесты пакета у потребителей не запускаются +**Где:** Р-1, принцип 1, «Что уходит из ядра» (пересчёт тем же кодом). +**Дыра.** +1. Расхождение копий невидимо на данных: ядро на старой копии пересчитывает после PATCH другим словарём/правилом, чем сервис при загрузке → статус/подсветка меняются от одной правки ячейки без изменения сумм (например, парсер уже знает новое соотношение, ядро — нет). В результате нет поля «версия `arm_statements`», ядро не может ни проверить, ни записать, каким кодом посчитан `checks`. +2. «`make check` потребителя проверяет свежесть копии» — проверка, читающая соседний репозиторий или сеть, — это ровно урок arm-core #33 (`03-architecture.md:180-182`: «тест, читающий соседний репозиторий, ломал main»; пайплайн платформы вне репозиториев, доступа к Gitea из него нет). `sync-web.sh` работает иначе: внешний скрипт в docs, точка синхронизации по хэшу дерева (`sync-web.sh:72-87`), код 20 при прямых правках. +3. «Копия заменяется строкой зависимости без смены импортов» — копия лежит в `api/src/arm_statements/` и импортируется как `src.arm_statements…` (все три репозитория импортируют через `src.`), пакет — как `arm_statements…`; импорты сменятся. +4. Тесты пакета остаются в `arm-statements`; у потребителя копия проверяется только его ruff/mypy с другими настройками (разные `noqa`, строгость) → `make check` ломается в одном репозитории и проходит в другом; golden-файлы в fixtures тоже должны прогоняться где-то у потребителя. +5. Загрузчики словарей разные (`parser/rules.py` `Rules` с `extra=forbid` и нормализацией «е/ё», `normalizer/rules.py` `OcrRules`, `domain/statement_checks.load_check_rules`) — словари пакета должны лежать внутри него со своим загрузчиком, иначе три загрузчика на один yaml. +**Как закрыть.** В контракт — `service.statements_version` (коммит копии); ядро пишет её в `recognition_job` и при пересчёте после PATCH сравнивает со своей (расхождение — предупреждение `error`, не падение). Свежесть копии — скрипт `sync-statements --check` в docs (как `sync-web.sh --check`), не тест и не `make check`. Копия с тестами (`api/tests/arm_statements/`) и единым конфигом линтеров в пакете. Импорт через `arm_statements` с добавлением пути (`pyproject`/`sys.path`) сразу, чтобы замена на зависимость была без правок. + +### F-18 — important — «Что уходит из ядра»: `RECOGNIZED ⇔ done ∧ ИНН ≠ MISMATCH` теряет `period_known` — документ `RECOGNIZED`, но не подтверждаемый (`PERIOD_REQUIRED`) +**Где:** «Что уходит из ядра» (`statement_status`), шаг 8 п. 1. +**Дыра.** Сегодня D-6 требует `period_known` (год и квартал: дата — конец квартала, `statement_mapping.py:220-240`), а подтверждение и автоподтверждение — тоже (`form_confirmable`, `services/statements.py:79-81`; `documents._check_statement` → 409 `PERIOD_REQUIRED`). Сервис про кварталы ядра не знает; дата отчёта 15.05.2025 (промежуточная форма) даст `done` → ядро ставит `RECOGNIZED` («ручная проверка не требуется») → `auto_confirm` молча пропускает → документ висит `RECOGNIZED` с кнопкой «Подтвердить», отвечающей 409. +**Как закрыть.** Оставить в ядре `period_known` (квартал) как условие `RECOGNIZED`, либо задать его в общем критерии `done` через параметр «период должен быть концом квартала». + +### F-19 — important — принцип 5 и шаг 4 п. 1: замена букв-двойников распространяется на суммы и коды вопреки §11.1 п. 1 (решение № 22: не проверено на стенде) — изменение не зафиксировано +**Где:** принцип 5 («очистка OCR — первая стадия типовых разборов … код строки (шаг 3), сумма (шаг 4)»), шаг 4 п. 1 («один `parse_amount(text)`: двойники, …»). +**Дыра.** §11.1 п. 1: двойники применяются **только к датам**; «замена двойников внутри кодов и сумм на стенде не проверялась, поэтому не делается (решение № 22 спеки 3a)». Сегодняшний `parse_amount` (`amounts.py:44-84`) двойники не трогает. Дизайн меняет правило молча: нет ни в Р-1…Р-7, ни в «Decisions made without discussion», §11.1 не правится. Цена: «О» → 0, «З» → 3, «б» → 6, «S» → 5 в сумме дают правдоподобное число с `is_recognized = false`, которое Р-7 затем может «подтвердить» через §7 (вместе с F-2 — без подсветки). +**Как закрыть.** Либо записать как решение (с условием: двойники в сумме/коде всегда дают `is_recognized = false` и не покрываются Р-7), либо оставить § 11.1 п. 1 и применять `fix_lookalikes` только к датам. + +--- + +## Minor + +### F-20 — minor — шаг 4 п. 1, решение № 6: общий `parse_amount` теряет проверки парсера (диапазон `numeric(18,2)`, квантование, дата/логическое) — Excel-дата становится суммой, «догадка из цифр» может уронить маппинг +**Где:** шаг 4 п. 1, п. 3, решение № 6. +**Дыра.** `to_money`/`quantize_money` парсера отсекают значения вне 16 целых цифр и округляют half-up (`numbers.py:85-97`), дату и bool дают `null` с предупреждением; `parse_amount` шлюза этого не делает (`amounts.py:72-84`), а «лучшая догадка — цифры подряд» и склейка строк (`split_merged_row` при несовпадении частей не разлепляет → «917 775\n2 371 333» → 9177752371333) дают сколь угодно длинные числа; контракт `money` длину не ограничивает, БД — `numeric(18,2)` → `MappingFatal` → файл `ERROR`. Excel-ячейка-дата «31.12.2024» через `parse_amount` → 31122024 (точек > 1 → удалены), `is_recognized = false`. +**Как закрыть.** В общий `parse_amount` — проверку диапазона/квантования из парсера; `value` типа дата/bool → `amount = null`, `string_value = text`, `is_recognized = false`; «догадка» ограничена 16 цифрами, иначе `null`. + +### F-21 — minor — шаг 3 п. 1 / шаг 2 п. 1: разбор кода должен убирать внутренние пробелы, иначе «1 100» (aep_3, уверенность 80, «Итого по разделу I») станет `unreadable` +**Где:** шаг 2 п. 1 («Код — 4–5 цифр (допускается `.0`)»), шаг 3. +**Дыра.** Парсер делает `text.replace(" ", "")` перед регуляркой (`columns.py:65`); `clean_value` шлюза пробел сохраняет (`text.py:57-62`), а `int("1 100")` падает. В дизайне правило не оговорено. +**Как закрыть.** Записать: код = текст без пробелов/переносов, 4–5 цифр, допускается `.0`; прочее — `unreadable` с сырым текстом. + +### F-22 — minor — шаг 6 п. 1 и шаг 2 п. 1: правило большинства знает только 1xxx/2xxx, а словарь кодов включает 5xxx (DISCLOSURE) — у расшифровок появятся строки, хотя §12 п. 8 их не отдаёт +**Где:** шаг 2 п. 1 («диапазонах словаря кодов всех форм»), шаг 6 п. 1, решение № 2. +**Дыра.** Лист «Пояснения» с кодами 5xxx получит раскладку и `RawLine[]`; в шаге 6 большинство 5xxx не относится ни к `BALANCE`, ни к `INCOME` — поведение не определено; ядро для `DISCLOSURE` строки не хранит (`_map_statement_docs`, `result_mapping.py:360-362`). +**Как закрыть.** 5xxx → `DISCLOSURE` в правиле большинства; для `DISCLOSURE` шаги 2–7 не выполняются (как сейчас), `lines`/`columns` пустые. + +### F-23 — minor — шаг 6 п. 3 и шаг 8 п. 3: `locator` `json:` противоречит §11 (`file:<имя json>;table:<i>`, решение № 1 спеки 3a) и golden-эталонам; код ошибки «документов нет» у сервисов разный, хотя написано «как сейчас» +**Где:** шаг 6 п. 3 (решение № 5), шаг 8 п. 3. +**Дыра.** Эталоны `fixtures/ocr/expected_normalize/*` и §11 фиксируют `file:…`; смена ключа — правка §11, golden и ожиданий ядра (`test_mapping_golden`). «Документов нет — `error` (как сейчас)»: у шлюза `RECOGNITION_FAILED`, у парсера `INVALID_DOCUMENT` — единого выхода нет. +**Как закрыть.** Либо оставить `file:`, либо явно перечислить, что меняется; код ошибки унифицировать или записать различие в таблицу шага 8. + +### F-24 — minor — шаг 2 п. 1: «нечитаемые коды становятся строками `unreadable` (так шлюз сейчас)» — неточно +**Где:** шаг 2 п. 1, проход (1). +**Дыра.** Сегодня нечисловой код → `RowKind.HEADER` → строка **пропускается** со счётчиком (`rows.py:58-71`, `_count_skipped`), а любое целое принимается кодом: в golden aep_3 есть строка с кодом «420» (уверенность срезана до 99) — это и не `unreadable`, и не код формы. Формулировка «так шлюз сейчас» вводит в заблуждение при написании спеки. +**Как закрыть.** Переформулировать: «проход по заголовку без порога — как у шлюза; статусы строк — новое поведение». + +### F-25 — minor — шаг 3 п. 2: правило разлепления при `unreadable` частях не определено; неразлепленная склейка даёт одну строку с многострочными значениями +**Где:** шаг 3 п. 2, §11.1 п. 4. +**Дыра.** `split_merged_row` требует `isdigit()` у каждой части кода (`rows.py:98`); «1500\n17O0» (двойник) не разлепится → по дизайну строка не отбрасывается, а становится `unreadable` с ячейками «917 775\n2 371 333» → шаг 4 «догадка из цифр» даст 9 177 752 371 333 с `is_recognized = false` (см. F-20). +**Как закрыть.** Если число частей кода ≥ 2 и части значений совпали по числу, разлеплять и при `unreadable` частях (статус наследуется частью); многострочное значение в неразлепленной строке — `amount = null`, `string_value = text`. + +--- + +## Что проверил и признал консистентным + +- Р-2: ИС отдаёт ячейку на каждую позицию каждой строки — подтверждено по всем 8 таблицам 4 успешных json (0 недостающих позиций из 2224); `column_index` с пропусками (0,1,2,4,5) — у всех таблиц одинаково внутри таблицы. +- Р-5 про дискретность уверенности сумм: в ячейках сумм промежуток 90–99 встретился один раз («2100 I 150416», 92,3 — это мусор); в ячейках наименований и кодов значения 90–99 обычны, но на статус по дизайну не влияют. +- Словарь §7: `tabular-parser/api/rules/statement_checks.yaml` и `arm-core/api/rules/statement_checks.yaml` сегодня совпадают по содержанию (различие только в комментариях) — утверждение «две копии» верно, расхождения пока нет. Словари строк шлюза (плоский, 66n, без порядка) и ядра (по формам, с порядком бланка) действительно не совпадают (разное число кодов, разные имена, например 1160) — утверждение шага 5 п. 3 верно. +- `ContractModel` во всех трёх репозиториях — `extra="ignore"`, `strict=True`: аддитивные поля (`needs_review`, `line_status`, `document.status`, `line_code_text`, `ref`) старыми потребителями проглатываются без ошибки; ломают только смена типов (F-7). +- Шаг 4 п. 5 ↔ Р-2 ↔ правило `needs_review` шага 8 (ячейка с уверенностью 0 и пустым текстом подсвечивается общим правилом) — согласованы. +- Шаг 6 п. 1 ↔ шаг 8 п. 1 ↔ решение № 2 (`DISCLOSURE` всегда `done`, документ без строк) — согласованы с текущим `_is_ok` и `_document` парсера. +- Шаг 9 «`checks` обязателен у форм со строками, отсутствует у документа без строк» ↔ решение № 7 — согласованы; у ядра `checks: OptionalNotNull` принимает оба случая. +- Решение № 5 (грамматика `locator`) соответствует текущим форматам обоих сервисов (кроме ключа `file:`/`json:`, F-23). +- «Достройка шаблонных строк: код, у которого есть строка из файла, не достраивается» — совпадает с текущим `statement_out` (`schemas/statements.py:205-208`) и `_resolve_cells` (`LINE_EXISTS`). +- Шаг 0 и шаг 9 (доставка, S3, NATS, `Nats-Msg-Id`) — не меняются, описание совпадает с `03-architecture.md`. +- Порог запасного прохода (решение № 3: ≥ 3 кодов, доля ≥ 0,6) не теряет ни одну таблицу OCR-фикстур (минимальная доля 0,78). +- `statement_period` ядра (дата отчёта, иначе max дат колонок) совпадает с формулировкой шага 2 п. 6 — расходится только шаг 8 п. 1 (F-6). +- Р-4 ↔ шаг 3 п. 3 ↔ шаг 5 ↔ шаг 7 (все строки `ok`, сумма по коду) — внутри дизайна согласованы; неучтённые следствия — в F-9, F-15. +- Р-6 ↔ шаг 2 п. 3/п. 6 ↔ решение № 1 (`error` у колонки без даты) — согласованы; неучтённое следствие — F-10. + +## Файлы и символы, которые смотрел + +scan-gateway: `api/src/normalizer/ocr/layout.py` (`build_layout`, `infer_layout`, `cells_by_index`), `rows.py` (`classify_row`, `_data_rows`, `split_merged_row`, `parse_table_rows`, `table_type_by_codes`, `_count_skipped`), `amounts.py` (`parse_amount`, `is_blank`), `dates.py` (`parse_date`, `_numeric_date`, `is_date_sized`), `text.py` (`fix_lookalikes`, `clean_value`, `is_code_header`, `is_value_header`), `header.py` (`parse_file_header`), `document.py` (`build_document`, `_resolve_type`, `_columns`, `_choose_edition`, `_value`, `_Build.warn`), `__init__.py` (`OcrNormalizer.normalize`, `_normalize_file`), `rules.py` (`OcrRules`), `contract/common.py`, `contract/result.py`, `api/rules/statement_labels.yaml`, `statement_lines.yaml`. +tabular-parser: `api/src/parser/statements/columns.py` (`_CODE`, `line_code`, `find_code_column`, `_find_header_row`, `_is_period_label`, `_period_columns`, `_joined_label`, `_value_columns`, `is_numbering_row`, `find_layout`), `lines.py` (`collect_lines`, `_value`, `_line_name`, `statement_money`), `merge.py` (`Draft`, `join_blocker`, `join`), `statement.py` (`parse_sheets`, `_block_draft`, `_continuation`, `_weak_blocker`, `_fill`, `_document`, `_is_ok`), `checks.py` (`run_statement_checks`, `_amounts`, `_filled`, `_holds`, `_subline_rules`), `detect.py` (`find_forms`, `continuation_form`, `is_weak_continuation`, `classify_statement_sheet`), `header.py` (`parse_header`, `_report_date`), `book.py` (`parse`, `_parse_book`), `grid.py`, `numbers.py` (`to_money`, `quantize_money`), `rules.py` (`StatementHeaderRules`, `StatementPeriodRules.period_end`), `contract/common.py`, `contract/result.py`, `api/rules/statement_checks.yaml`, `statement_periods.yaml`, `statement_headers.yaml`, `api/tests/statements_manifest.yaml`, `tests/unit/parser/statements/test_lines.py` (ожидание «строк без кода с суммами»). +arm-core: `api/src/domain/statement_mapping.py` (`build_statement`, `statement_status`, `values_ok`, `statement_period`, `edition_for`), `domain/statement_checks.py` (`run_checks`, `_holds`, `subline_parents`, `_subline_rules`, `load_check_rules`, `cross_form_check`, `CODE_RANGES`), `domain/statement_lines.py`, `services/result_mapping.py` (`map_file`, `_map_statement_docs`, `_insert_statements`, `MAPPING_WARNINGS_KEY`), `services/statements.py` (`statement_hard_failures`, `form_confirmable`, `checks_report`, `build_out`, `recompute_status`, `_apply_cells`, `_resolve_cells`, `_apply_header`, `patch`, `accept_checks`), `services/auto_confirm.py`, `services/documents.py` (`document_out`, `_check_statement`), `repositories/statements.py` (`insert_statement`, `statement_amounts`, `value_flags`, `load_form`, `_line_amount`, `_pair_document`, `add_line`, `add_value`, `form_lines`), `schemas/statements.py` (`CellPatch`, `line_origin`, `_lines_out`, `statement_out`), `models/statements.py` (`FinancialStatementLine`), `contract/common.py`, `contract/result.py`, `api/rules/statement_checks.yaml`, `statement_lines.yaml`, `tests/test_api_statements.py` (`test_patch_duplicate_line_code_rejected`); фронт `web/src/widgets/DocumentReview/statementGrid.ts`, `StatementTable.tsx`, `ChecksPanel.tsx`, `formLayout.ts`, `web/src/types/api.ts`. +docs: `openspec/changes/unified-statement-normalization/design.md` (целиком), `context/05-domain-rules.md` §7, §11, §11.1, §12, `contracts/recognition-result.schema.json`, `model/data-model-v5.18.dbml` (`document`, `recognition_job`, `financial_statement_*`, `document_status`), `context/03-architecture.md` (строки 95–110, 172–190), `scripts/sync-web.sh`. +fixtures: `ocr/aep_3.json`, `aep_3_ofr.json`, `aep_5.json`, `aep_6.json`, `aep_8_form4.json`, `БО 1 кв 2025.json`, `Бухгалтерская отчетность 1 кв.25.json`, `ocr/expected_normalize/*` (скриптами `python3 -I`). diff --git a/openspec/changes/unified-statement-normalization/review-2026-10-18-spec-2.md b/openspec/changes/unified-statement-normalization/review-2026-10-18-spec-2.md new file mode 100644 index 0000000..ab5ab86 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/review-2026-10-18-spec-2.md @@ -0,0 +1,241 @@ +# Повторное ревью спеки вслепую (18.10.2026, Opus, коммит 248f8b0) + +Ревьюер: отдельный агент (модель Opus), без права правки, режим «чёрный ящик» — не открывал design.md и прошлые +отчёты; решения писателя S-1…S-52 получил отдельным файлом. Маркеры находок — L-n. + +Сверка перед передачей владельцу (сессия): подтверждены L-3 (DBML v5.19 — уникальность `(stored_file_id, file_ordinal)` +на всех документах), L-4 (`arm-core/api/src/schemas/files.py` — `JobBrief.warnings: list[str]`), L-5 (сценарий «3450» +в спеке statement-normalization при отрезке 3100–3699). + +Отчёт агента ниже без сокращений. + +--- + +# Ревью спеки `unified-statement-normalization` (вслепую, коммит 248f8b0) + +## 1. Вердикт + +Спека зрелая. Цепочка шагов 1–8, контракт v2.0, DBML v5.19, §7/§9/§11 и перечень кодов в основном согласованы. `openspec validate --strict` проходит, `validate_contracts.py` зелёный (18 примеров + перечень). Схемы `*.v1.schema.json` побайтно равны схемам из 43b4b80. + +К написанию плана спека **условно готова**. Сначала нужно закрыть одну критическую находку и примерно десять важных. + +**Что блокирует план:** +- **L-1.** Не определено, входят ли значения строк `detail` в `needs_review` и в статус. От этого зависит, станут ли 4 из 6 реальных OCR-документов `done`. Нормативный текст спеки противоречит §11.4. +- **Переход и выкладка:** + - правило выбора схемы по `$id` ломает тест идентичности для `recognition-request` и для примеров; + - ядро не входит в шаг 0; + - `file_ordinal` задан только у форм, а уникальный индекс стоит на всех документах; + - предупреждения ОСВ и `JobBrief.warnings` в 2.0 никуда не отображены. +- **Раскладка:** не задано, как колонки сегмента со своей шапкой сопоставляются с колонками документа. Это напрямую касается сегодняшнего `done` НПЗ (листы 4 ББ и 3 ОФР со своей шапкой). +- **Разбор суммы:** нет правила «какие символы выбрасываются и когда `amount = null`». Сценарий `211(1 |` → `string_value` из этих правил не выводится. + +**Что проверено на данных:** +- Excel из манифеста не даёт ни одной строки `unreadable`/`missing`. +- Нумерация Межениновской (строки 24 и 49) распознаётся новым детектором. +- Сегодняшние `done` сохраняются, если L-6 решить «по порядку» или «по дате». + +## 2. Находки + +### critical + +**L-1. `needs_review` значений строк `detail` и статус документа.** +- Где: `statement-normalization` — «Шаг 8 — `needs_review` значения» («SHALL вычислять `needs_review` у каждого значения») и «Шаг 8 — статус» («нет значений… с `needs_review`»). Против: §11.4 «`detail`… в проверки §7, агрегаты и `done` она не входит». В `arm-web` строки `detail` скрыты переключателем. Проверка согласованности ядра (§9.3, S-48) тоже смотрит «значение с `needs_review`». +- Проблема: у значения строки `detail` нет кода, поэтому покрыть его соотношением нельзя. Любая такая ячейка с уверенностью ниже 100 даёт `needs_review = true`, и по нормативному тексту документ уходит в `needs_check`. При этом причина спрятана под переключателем. +- Проверено скриптом на фикстурах (`scratchpad/rev3/cov.py`). В ББ и ОФР РБ («Бухгалтерская отчетность 1 кв.25») и Счастливочной («БО 1 кв 2025») **все** неуверенные значения строк с кодом покрыты соотношениями. Жёсткие соотношения сходятся. Неуверенные коды подтверждаются перечнем `fsbu4` целиком, разлепленные 1500/1700 тоже покрыты. Держат документ только значения строк `detail` (`'86 157'@67`, `'134'@33`, `'(115 464)'@78` и др.). +- Итог: если `detail` исключить, 4 документа из 6 станут `done`; если включить — 0. Ожидание «доля done небольшая» (proposal, §11.9) верно только во втором случае, но явно этого нигде не сказано. +- Предложение: явно записать в шаге 8 и в функции статуса, что значения `detail` в `done` не входят (`needs_review = false` или исключаются из входа функции; подсказку оставить по `text`/`confidence`). Либо наоборот — оставить и потребовать у экрана счётчик «скрытых сомнительных ячеек» у переключателя. В любом случае нужен сценарий на РБ. + +### important + +**L-2. Тест идентичности на окне перехода: правило `$id` неполно.** +- Где: `recognition-contract`, требование «Копия контракта…», шаг 0 proposal, `dev/checks.md`. +- Проблемы: + - `recognition-request.schema.json` в docs имеет `$id` `arm-ka/recognition-request/v1`, а файла `recognition-request.v1.schema.json` нет. По написанному правилу тест ищет несуществующий файл и падает либо молча пропускается («недоступно — пропуск»). + - На шаге 0 (до слияния docs) файлов `*.v1.schema.json` ещё нет вообще. + - Существующие тесты (`arm-core/api/tests/test_contract.py:109-125`, `tabular-parser/api/tests/unit/test_contract.py:113-126`, `scan-gateway/api/tests/unit/test_contract.py:120-131`) сравнивают **весь набор `*.json` через `rglob`, включая `examples/`**. После шага 2 в docs появятся `examples/v1/*`, `*.v1.schema.json`, а `examples/*.json` станут v2.0. Набор и байты не совпадут у всех трёх репозиториев. + - У ядра такой же тест, но в шаге 0 ядра нет: между шагами 2 и 3 `make check` в main arm-core красный. +- Предложение: + - файл `<имя>.v1.schema.json` брать, только если он есть, иначе основной; + - явно описать сравнение примеров (копия v1 ↔ `examples/v1/`, файлы другого MAJOR игнорировать); + - включить arm-core в шаг 0 (или сливать шаги 2 и 3 одновременно); + - добавить сценарий «запрос v1.0 после слияния v2.0». + +**L-3. `file_ordinal` и уникальный `(stored_file_id, file_ordinal)`.** +- Где: `arm-core` «Хранение результата формы v2.0» — `file_ordinal` назначается только `BALANCE`/`INCOME`. Маппер 1.x о нём молчит. В DBML `default 0` и `unique (stored_file_id, file_ordinal)`. +- Сценарий: книга с двумя листами ОСВ, или PDF ББ+ОФР по 1.x → два документа с `file_ordinal = 0` → нарушение уникальности → файл `ERROR`. +- Предложение: «каждый `document` (ОСВ, формы, `DISCLOSURE`/`OTHER`) при любом MAJOR получает `file_ordinal` = индекс в `documents[]`». Добавить сценарий «книга из двух ОСВ». + +**L-4. Предупреждения файла и ОСВ в 2.0 никуда не отображены в ядре и API.** +- `metrics.warnings` становится массивом структур, а 1.x тоже конвертируется в структуры `LEGACY_WARNING`. При этом `JobBrief.warnings: list[str]` (`arm-core/api/src/schemas/files.py:59`) читается через `_strings(metrics.get("warnings"))` (`services/documents.py:67`). Структуры отфильтруются, и карточка файла потеряет `JSON_FAILED`/`TABLE_LOST`/`FILE_TYPE_MISMATCH`. +- `osvDocument.warnings` (`OSV_*`) и `osvDocument.status` в спеке `arm-core` не отображены никуда. Сегодня предупреждения ОСВ видны в `job.warnings` (строки `sheet=…`), после перехода tabular-parser на 2.0 они исчезнут из UI. +- В recognition_job будут лежать и строки (старые задания), и структуры. +- Гайд фронта утверждает «поля добавляются, старые не удаляются» — для `JobBrief.warnings` это неверно. +- Предложение: требование в `arm-core` — форма `JobBrief.warnings` (структуры или `message`), где хранятся и показываются предупреждения ОСВ, используется ли `osvDocument.status`. + +**L-5. Ошибка в сценарии «Год и сумма — не код».** +- Где: `statement-normalization` «Диапазоны кодов». +- Проблема: «3450» лежит внутри отрезка 3100–3699 (отчёт об изменениях капитала), то есть по правилу это код. Тест, написанный по сценарию, упадёт. +- Предложение: заменить на число вне отрезков (1050, 7450) или ограничить «кодовые диапазоны» для поиска столбца. + +**L-6. Сопоставление колонок сегмента со своей шапкой не задано.** +- Где: «Шаг 2 — сегменты», «Шаг 2 — номер колонки». +- Проблема: правило «по порядку» задано только для сегментов **без** шапки. Для повторной шапки (OCR) и листа-продолжения со своей шапкой (НПЗ «Бухгалтерский баланс. Лист 4» — колонки 8–10 при 16/21/24 у первого листа; «Отчет о финансовых результата_3») не сказано, становятся ли колонки новыми `columns[]` или как они сопоставляются (по порядку? по `period_date`?). +- Сценарий: исполнитель создаёт новые колонки → суммы разных листов в разных колонках → §7 НПЗ ББ ломается → сегодняшний `done` становится `needs_check`. +- Предложение: «колонки сегмента с шапкой сопоставляются с колонками первого сегмента по `period_date`, иначе по порядку; число не совпало — `SEGMENT_COLUMNS_MISMATCH`». Добавить сценарий на НПЗ. + +**L-7. `parse_amount`: не задано, какие символы выбрасываются и когда `amount = null`.** +- Где: шаг 4. +- Проблема: правило перечисляет двойники, `|`, пробелы, скобки, прочерк. Но сценарии требуют выбрасывать `:`, `=`, `■`, `•` («| : 122477» → 122477), а сценарии `arm-core` и `scan-gateway` ждут, что `211(1 |` даст `string_value` без `amount`. +- Текущий `amounts.py` (`scan-gateway/api/src/normalizer/ocr/amounts.py:56-90`) выбрасывает всё, кроме цифр, `+-.`, и дал бы `211(1 |` → 2111, `(Ю4)` → −4. +- Предложение: явное правило — какой мусор выбрасывается (с `is_recognized = false`), когда результат `null` + `string_value` (непарная скобка, буква внутри цифр, доля мусора). Сценарии на `211(1 |`, `(Ю4)`, `: : 2856983 = ■`. + +**L-8. `sync-statements --check`: код 20 при дрейфе перечня кодов в docs.** +- Проблема: правка `statement-code-orders.yaml` в docs до обновления `arm-statements` и пересинхронизации даёт `check-full` = 20 (красный) во **всех** хостах. Это противоречит обоснованию S-30 («отставшая копия не должна блокировать посторонние PR»). +- Предложение: сверять копию перечня с версией docs, соответствующей копии (или выдавать 10, если копия равна дереву своего `VERSION`). Либо зафиксировать порядок «docs и arm-statements сливаются в один день». + +**L-9. Полярность `needs_review` при приёме 1.x и в миграции.** +- Где: `recognition-contract` («`needs_review` значения — по прежнему `values_ok` (`is_recognized` не `false` и `confidence` 100 или пуста)»), `arm-core` «Миграция», §9.5, шапка DBML. +- Проблема: в тексте описано условие **OK**, будто это значение `needs_review`. Буквальная реализация ставит `needs_review = true` чистым значениям. Сценария, ловящего это, нет. +- Предложение: «`needs_review = NOT values_ok`» и сценарий. + +**L-10. Публичный API пакета для пересчёта в ядре не описан.** +- Спека задаёт вход функции статуса, но не вход пересчёта §7 и `needs_review` из строк БД. +- Не описано, как передать: + - `is_modified`/`is_confirmed`/`line_code_modified`; + - исключение правленых ячеек из голосования знака; + - `form_variant`, эффективную дату для редакции; + - строки шаблонные и `detail`; + - что делать с `needs_review_fields` и `form_variant` после правки кода или даты колонки. +- Риск: пакет пишется под `FormCandidate` сервисов, и ядро не может им воспользоваться без реконструкции сетки. +- Предложение: требование «Точка входа пакета для пересчёта по сохранённым данным» с типами и сценарием. + +**L-11. Следствие одобренного S-49: правило «итого по разделу — только равенство» ломается на реальной фикстуре.** +- Счастливочная: разлепленная строка 1500 (уверенность 56) с наименованием «Итого по разделу V1» → `NAME_MISMATCH` → `NEEDS_CHECK`, хотя документ полностью сходится. +- РБ: «Итого по разделу V /» совпадает, только если после нормализации обрезаются крайние пробелы. Нормализация этого не говорит. +- Предложение: сравнивать для «итого по разделу» римский номер раздела (первый токен после фразы), явно делать `strip`. +- Кроме того: словарь имён ядра fsbu4 не содержит 1105/1130/1140/1530/2460. Синонимы Р-20 (1220 «НДС, начисленный по отгрузке», 1520 «Обязательство по аренде», 2410 УСН) нужно **явно** потребовать добавить в `statement_lines.yaml`. Сейчас это есть только в сценарии. Без этого законные повторы РБ/Счастливочной/aep_6 дают `NAME_MISMATCH`, а неуверенный 1105 всегда даёт `NAME_MISMATCH`. + +**L-12. Страховка ядра (S-48) уже функции статуса.** +- Проверка «согласован» не включает строку `unreadable` без значений, `layout_found`, `has_organization_or_inn`, `period_known`. +- Сценарий: сервис по ошибке прислал `done` при строке `unreadable` без значений → ядро `RECOGNIZED` и автоподтверждение, хотя функция статуса даёт `needs_check`. +- Предложение: «согласован ⇔ общая функция статуса по входу, собранному из результата, даёт `done`». Это не пересчёт `needs_review` и §7, а вызов той же функции. + +**L-13. Миграция задним числом меняет подтверждаемость существующих форм.** +- `document_issue` при миграции считается по новому правилу «посчитанного» соотношения. Пустая цель теперь нарушение: страницы 2 ББ без 1600, «только актив». +- Сценарий: `NEEDS_CHECK`-форма, которую до миграции можно было подтвердить, после миграции получает 409 `CHECKS_NOT_PASSED`. +- Импорт живого кода пакета из ревизии Alembic невоспроизводим (S-36 это признаёт). +- Предложение: признать следствие в спеке (сценарий) или считать при миграции по прежнему правилу. Вынести вопрос владельцу. + +### minor + +- **L-14.** §11.4 приводит «изо :» как пример `unreadable`, но цифр там нет. По правилу это пустой код, и в aep_3 строка (1130, значение `'3'@0`) уйдёт в `detail` и будет скрыта. Исправить пример или правило (считать цифрой и двойники). +- **L-15.** «Ближайшая строка с кодом выше» для `detail`: не сказано, `ok` ли это (если выше стоит `unreadable`). При отметке `ok` → `detail` не сказано про перепривязку детей этой строки (`parent_row_index`). +- **L-16.** Ручная дата колонки (PATCH `columns`) не меняет `period_kind`. Если колонка была `relative:*`, следующая правка `report_date` перезапишет ручную дату. Нужно: ручная дата → `absolute`. +- **L-17.** `COLUMN_DATE_UNCERTAIN` нельзя снять подтверждением той же даты: «запрос без изменений — 200 без эффектов». Нужен аналог S-42 для колонок. +- **L-18.** Подпись OCR с уверенностью ниже 100 подтверждается «ожидаемыми датами» от эффективной даты. Если даты отчёта нет, эффективная дата может определяться этой же подписью, и она подтверждает сама себя. Исключить её при вычислении. +- **L-19.** LIS: + - не определено, что значит «уверенные коды — якоря» (обязательное включение или вес); + - не сказано, что делать при конфликте якорей между собой или с перечнем; + - `form_variant` определяется «по структуре кодов» — каких строк (`ok`?); + - пересчитывается ли `form_variant` после назначения кода — не сказано. +- **L-20.** «Форма записи знака по `text`» для `{38378)` не определена: `{` не принимается за открывающую скобку. Нужно «форма знака — по результату разбора: скобки / минус / без знака». Не сказано и то, входит ли сама ячейка в «большинство». +- **L-21.** `CODE_NOT_IN_EDITION` и `CODE_NOT_IN_DICTIONARY`: какая редакция берётся — по дате (`edition`) или выбранная LIS. Межениновская на бланке 66н в 2025 году получит `info` на 1120. +- **L-22.** В схеме `form_variant` не входит в `required`, хотя спека говорит «SHALL иметь». `OTHER` в 2.0 никогда не порождается (лист «прочее» → `SHEET_OTHER`, документа нет), но остался в enum и в правилах ядра. +- **L-23.** Сценарий arm-core «Два баланса в одном файле… ни один не автоподтверждается» безусловный. Правило Р-18/S-45 требует **того же периода**. +- **L-24.** Откат: + - DBML (шапка v5.19) считает `crm_id` групп несовместимостью для `downgrade`, спека — нет; + - `downgrade` неизбежно удаляет `is_confirmed`, `document_issue`, `header_source`, что противоречит «MUST NOT удалять данные молча». Сформулировать точно. +- **L-25.** Механика копии `arm_statements`: + - `VERSION` лежит в копии, но его нет в дереве источника — исключить при сравнении; + - зависимости пакета (PyYAML и др.) должны быть в `requirements.txt` хостов (зеркало банка); + - `mypy` не поддерживает конфиг каталога копии: строгость хоста применяется к коду пакета, а править копию нельзя. Нужно требование «пакет проходит самый строгий конфиг хостов»; + - `conftest` хоста может влиять на тесты пакета. +- **L-26.** «Шаг 1 — многостраничная форма» сформулирован для «каждого адаптера», но у OCR кандидат строится на каждую таблицу ИС, склейки нет. Явно ограничить требование Excel. +- **L-27.** `TABLE_LOST` — «таблица ИС с содержимым» без определения содержимого таблицы. +- **L-28.** `validate_contracts.py` пишет «не строка из 4 цифр», а регулярка допускает 5. Не проверяет, что число копий повтора совпадает с `repeats`. +- **L-29.** Сценарий 1.x в `recognition-contract` («обе строки кода — по прежнему правилу «остаётся первая»») читается двусмысленно. +- **L-30.** Значение с текстом без цифр и уверенностью 100 (`'I'@100` в строке без кода, aep_6 строка 38) не делает строку строкой данных и молча теряется. Это противоречит принципу «ничего не выбрасывать молча». Признать или включить в «содержимое». + +## 3. Решения писателя + +| S-n | Суть | Оценка | Обоснование | +|---|---|---|---| +| S-4 (утв., Р-22 / S-49) | Совпадение наименований | согласен с оговоркой (следствие) | Утверждено. Следствие на фикстурах: «Итого по разделу V1» у Счастливочной → `NAME_MISMATCH`; не задан `strip` (L-11). | +| S-5 (утв. с изм.) | Упрощённый набор, ОФР 2400 мягкое | согласен | Утверждено. В упрощённом ОФР нет ни одного жёсткого соотношения, покрытие только мягким точно — так и задумано. | +| S-7 (утв.) | Прочерк = пусто, `true` | согласен | Тире на месте цифры ловит §7. | +| S-10 (утв.) | `needs_review_fields` только для даты | согласен | Сформулировано одинаково во всех документах. | +| S-12 (утв.) | Ожидаемые даты + 31.12 | согласен с оговоркой | Замкнутость при отсутствии даты отчёта (L-18). | +| S-13 → S-40 | Детектор нумерации | согласен | Проверено на Межениновской (строки 24, 49) — нумерация распознаётся. | +| S-14 (утв.) | Признак повторной шапки | согласен | Не задано сопоставление колонок такого сегмента (L-6). | +| S-15 (утв. с ог.) | `ref`, `row_index`, экранирование листа | согласен | Примеры приведены к правилу. | +| S-17 → S-43/S-47 | Формат PATCH `lines` | согласен | — | +| S-19 (ог.) | Имена DBML, `codes`, `column_index` | согласен с оговоркой | `file_ordinal` уникален для всех документов, а спека задаёт его только формам (L-3). | +| S-20 (утв.) | `crm_id` уникален | согласен | Добавить в условия отката (L-24). | +| S-21 → S-36 | Значения по умолчанию 1.x | согласен с оговоркой | Полярность `needs_review` (L-9). | +| S-22 (утв.) | Статус ОСВ | согласен с оговоркой | Ядро `osvDocument.status` и `warnings` нигде не потребляет (L-4). | +| S-24 → S-26 | Редакция в сервисах | — | см. S-26. | +| S-25 (утв.) | Квартал → `NEEDS_CHECK`, `PERIOD_REQUIRED` | согласен | — | +| S-26 | Всегда обе редакции, дата решает ничью | согласен | Межениновская 66н-2025 иначе не подтвердилась бы. **Меняет смысл дизайна (выбор по дате), владельцем не утверждено.** | +| S-27 | Перечень в docs, байтовая копия в пакете | согласен с оговоркой | Дрейф перечня делает все хосты красными (L-8). | +| S-28 | Только коды с образцом | согласен с оговоркой | Старые бланки 66н (2421/2430/2450) с неуверенным кодом дадут `needs_review`; приемлемо при OQ-168. | +| S-29 | Выбор схемы по `$id` | не согласен | `recognition-request` (`$id` /v1, без `.v1`-файла), примеры и ядро не покрыты; на шаге 0 `.v1`-файлов ещё нет. Предложение — L-2: брать `.v1`, только если файл существует; правило для `examples/`; ядро в шаг 0. | +| S-30 | Механика `sync-statements` | согласен с оговоркой | `VERSION` в сравнении, зависимости, `mypy`-конфиг, код 20 при дрейфе перечня (L-8, L-25). | +| S-31 | `column_index` = столбец сетки | согласен с оговоркой | Нужно правило для сегментов со своей шапкой (L-6). Меняет смысл поля контракта — допустимо в MAJOR. | +| S-32 | `TABLE_PARSE_ERROR`, `SHEET_OTHER` — уровень файла | согласен | — | +| S-33 | `header_source` jsonb | согласен | — | +| S-34 | PATCH `organization_name` | согласен с оговоркой | Нужен, иначе `NO_ORGANIZATION_NO_INN` не снять. **Расширение функционала — не утверждено владельцем.** | +| S-35 | `RELATION_FAILED`/`RELATION_SOFT`, `codes` | согласен | — | +| S-36 | §7 один раз для 1.x и в миграции | согласен с оговоркой | Задним числом меняет подтверждаемость существующих форм; импорт живого пакета из Alembic (L-13). **Меняет смысл — не утверждено.** | +| S-37 | Строка правки ⇔ `row_index IS NULL` | согласен | Миграция корректна (маппер 3b всегда пишет `line_code_original_value`, `repositories/statements.py:80`). **Отступление от дизайна — не утверждено.** | +| S-38 | Вход функции статуса; `unreadable` без значений | согласен с оговоркой | Не решён вопрос `detail` в `needs_review` (L-1, critical). | +| S-39 | Столбец кодов ищется и у `DISCLOSURE` | согласен | — | +| S-40 | Детектор нумерации с двойниками и пропуском | согласен | Ложных срабатываний на фикстурах нет. | +| S-41 | `LINES_UNRESOLVED` — сумма или текст | согласен | Мусор на месте суммы требует разбора. **Расширяет Р-13 — не утверждено.** | +| S-42 | Подтверждение даты тем же значением | согласен с оговоркой | Нужен аналог для даты колонки (L-17). | +| S-43 | Имена (`LINE_CODE_TAKEN`, `period_date`) | согласен | — | +| S-44 | Откат | согласен с оговоркой | `crm_id` и неизбежная потеря новых колонок (L-24). | +| S-45 | «Тот же период» = (год, квартал) | согласен | Поправить безусловный сценарий (L-23). | +| S-46 | Единицы и ОКЕИ | согласен | — | +| S-47 | Пробелы PATCH; подтверждение не сбрасывает акцепт | согласен по существу | **Сужает правило Среза 3b «акцепт сбрасывается любой правкой» — ждёт владельца.** | +| S-48 | Страховка `done` → `RESULT_STATUS_INCONSISTENT` | согласен с оговоркой | Проверка уже функции статуса; предлагается вызывать саму функцию (L-12). **Ядро переопределяет статус сервиса — не утверждено.** | +| S-49 | Синонимы; «итого по разделу» — только равенство | согласен с оговоркой | Ломается на «Итого по разделу V1»; синонимы Р-20 и 1105 явно добавить в словарь (L-11). | +| S-50 | Толкование Р-21 | согласен | Проверено на РБ/Счастливочной: 2400/2410/2411/25xx покрываются мягкими точно. | +| S-51 | Ожидания на реальных сканах | согласен с оговоркой | Верно, только если значения `detail` держат статус; иначе 4 из 6 документов `done` (L-1). | +| S-52 | Манифест tabular-parser | согласен | Подтверждено разбором: строк `unreadable`/`missing` в Excel нет; страницы 2 ББ дают `1600 = 1700` с пустой целью. | + +**Меняют смысл и ещё не утверждены владельцем:** S-26, S-34, S-36, S-37, S-41, S-47 (явно вынесено владельцу), S-48. Пограничные: S-31 (смысл поля контракта) и S-38 (`unreadable` без значений → `needs_check`, но подтверждение не блокирует). + +## 4. Что проверил и признал согласованным + +**Согласовано:** +- Поля контракта v2.0 ↔ DBML v5.19 ↔ маппинг `arm-core`: строки, значения, колонки, шапка (`header_source`, `*_original_value`), `document_issue` (`kind`/`code`/`severity`/`codes`/`column_id`/`column_index`/`field`), `statements_version`, `form_variant`, `needs_review_fields`. +- Условные требования схемы: `line_code` null ⇔ статус не `ok`; `parent_ref` у `detail`; `checks` у `BALANCE`/`INCOME` со строками; `statements_version` при наличии формы. +- Порядок 409 одинаков в спеке, §9.5 п. 4 и гайде фронта. Коды 422 одинаковы. +- Таблица предупреждений спеки и §11.9 совпадают по кодам и уровням. +- Миграция `row_index` согласуется с текущим кодом ядра. +- `sync-statements --check` на текущих данных пройдёт: все коды словаря ядра есть в перечне своей редакции. В обратную сторону в словаре нет 1231; 2530, 2930; 1105, 1130, 1140, 1530; 2460. +- Сценарии разбора кода и сумм («1220 ,», «| 140», «I1О0», «1 2О5», «(7744536}», «4,200», «42831 1») выводятся из правил. Исключения — «3450» (L-5) и «| : 122477» (L-7). +- Формат OpenSpec: MODIFIED в `recognition-contract` и `quality-checks` содержат полный текст с прежними сценариями, заголовки совпадают с `openspec/specs`. REMOVED нет, и он не нужен. +- Ручной прогон aep_3 и aep_6 подтверждает обещанное: `missing` «Выручка», `detail` «БАЛАНС» с нарушением `1600 = 1100 + 1200`, `unreadable` «44 30\nI |» без разлепления, законные повторы 1220/1520/2410 и раздел III НКО укладываются в перечни `fsbu4`/`66n`. + +**Файлы:** +- `openspec/changes/unified-statement-normalization/proposal.md` и все `specs/*/spec.md`; +- `openspec/specs/recognition-contract/spec.md`, `openspec/specs/quality-checks/spec.md`; +- `docs/contracts/*.schema.json` (v2 и v1), `statement-code-orders.yaml`, `examples/*`, `examples/v1/*`, `scripts/validate_contracts.py` (запущен), `Makefile`; +- `context/05-domain-rules.md` §1.6, §7, §9.3–§9.5, §11–§12; +- `context/07-open-questions.md` (диф), `context/03-architecture.md` (диф); +- `model/data-model-v5.19.dbml` (диф к v5.18), `model/entities-v5.19.md` (диф); +- `dev/frontend-api-guide.md` §5.6.1, `dev/checks.md` (диф); +- файл решений `writer-decisions-2.md`. + +**Код и данные:** +- `fixtures/ocr/*.json` (все таблицы aep_3, aep_6, РБ и Счастливочной за 1 кв. 2025) и `fixtures/ocr/expected_normalize/*`; +- манифест `tabular-parser/api/tests/statements_manifest.yaml` и все его Excel-файлы — прогоном через `read_book_detected`, `find_forms`, `find_layout`, `line_code` tabular-parser; +- tabular-parser: `api/rules/statement_checks.yaml`, `src/parser/statements/statement.py`, `book.py`, `grid.py`; +- scan-gateway: `src/normalizer/ocr/amounts.py`, `layout.py`; +- arm-core: `api/rules/statement_lines.yaml`, `src/schemas/files.py` (`JobBrief`), `src/services/documents.py:39-68`, `src/repositories/statements.py:80`, `src/schemas/statements.py:158`, `tests/test_contract.py:100-125`; +- тесты идентичности контракта сервисов (`tests/unit/test_contract.py`) и Dockerfile трёх репозиториев (`COPY api/` — копия пакета в образ попадёт). + +Codegraph не использовал: нужные места знал точно, читал файлы напрямую. + +Скрипты прогона лежат в `/private/tmp/claude-501/-Users-andrey-epifancev-projects-awp-ca/12de9a9a-5665-41d0-beea-0651270d88ec/scratchpad/rev3/`: `dump.py` (таблицы ИС), `cov.py` (покрытие §7 на OCR), `xl.py` и `xl2.py` (статусы строк и колонки Excel). \ No newline at end of file diff --git a/openspec/changes/unified-statement-normalization/review-2026-10-18-spec.md b/openspec/changes/unified-statement-normalization/review-2026-10-18-spec.md new file mode 100644 index 0000000..51a0764 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/review-2026-10-18-spec.md @@ -0,0 +1,264 @@ +# Ревью спеки вслепую (18.10.2026, Opus) + +Ревьюер: отдельный агент (модель Opus), без права правки, режим «чёрный ящик» — не открывал design.md и прошлые +отчёты; решения писателя S-1…S-25 получил отдельным файлом. Маркеры находок — K-n. + +Сверка перед передачей владельцу (сессия): подтверждены по коду K-1 (`arm-core/api/src/schemas/statements.py:155-157` +— `origin` по `line_code_original_value IS NULL`), K-4 (запрет повтора кода в загрузчике словаря имён, +`arm-core/api/src/domain/statement_lines.py:47-49`; повторы 1220/1520 с уверенностью 50 в OCR-фикстурах подтверждались и +во втором раунде ревью дизайна), K-12 (`HeaderPatch` — только `inn`, `report_date`, `units`, `extra="forbid"`). + +Отчёт агента ниже без сокращений. + +--- + +# Ревью спеки `unified-statement-normalization` (режим «чёрный ящик», 18.10.2026) + +`design.md` и `review-2026-10-17*.md` я не открывал. Файлы не правил, ничего не коммитил. + +## 1. Вердикт + +Спека подробная и в основном согласованная. `openspec validate unified-statement-normalization --strict` проходит, все 10 примеров контракта валидны по схемам v2.0 (`scripts/validate_contracts.py`). Шаги 2–8 цепочки описаны так, что исполнитель `arm-statements` может писать код и тесты почти без догадок. + +**К плану спека пока не готова: блокируют две critical-находки в arm-core.** +- **K-1.** Признак «строка не из источника» сейчас — `line_code_original_value IS NULL`. В v2.0 он ломается, а миграция проставляет `row_index` строкам, созданным правкой. В итоге исходная строка может быть удалена или заблокирована как «шаблонная». +- **K-2.** Не сказано, как в v2.0 живут документы, принятые по 1.x, и уже существующие документы. Копия словаря §7 у ядра удаляется, `form_variant = null`, `document_issue` пуст. Экран и подтверждение для всех существующих форм потеряют нарушения §7. + +До плана нужно также закрыть important-находки: +- хранение `*_text` и `*_confidence` шапки в DBML (K-3); +- данные упорядоченного перечня кодов (K-5); +- смысл `column_index` (K-6); +- уровень `TABLE_PARSE_ERROR` и `SHEET_OTHER` (K-7); +- окно перехода против теста идентичности копий схем (K-8); +- механика `sync-statements` (K-9). + +Прогон правил на фикстурах ниже, в K-4 и K-25: сегодняшние `done` у Excel не ломаются. Но ни один из 6 реальных OCR-документов не станет `done`, и спека этого не признаёт. + +## 2. Находки + +### Critical + +**K-1. Признак «строка создана правкой» и `row_index` в миграции.** +- **Где.** `specs/arm-core/spec.md`: требования «PATCH формы», «Миграция DBML v5.19», «Словари ядра и достройка шаблонных строк». DBML v5.19 `financial_statement_line`. +- **Как сейчас.** `origin` вычисляется как `"template" if line.line_code_original_value is None else "source"` (`arm-core/api/src/schemas/statements.py:157-159`). `add_line` ставит `line_code_original_value=None` как признак «не из источника» (`arm-core/api/src/repositories/statements.py:223-229`). +- **Что меняет v2.0.** + - У строк `unreadable` / `missing` / `detail` из источника `line_code` пуст. Спека не говорит, что маппер пишет в `line_code_original_value`. + - Сценарий «Назначение кода строке-мусору» прямо требует `line_code_original_value = null`. + - Правила 409 `LINE_EXISTS` и «без сумм — удаляется» опираются на понятие «шаблонная строка, созданная правкой аналитика», но признак не определён. + - Миграция заполняет `row_index` «по возрастанию `line_code` внутри формы» у **всех** строк, включая созданные правкой. Это противоречит «Строки, созданные правкой: `row_index = null`». +- **Сценарий.** Аналитик назначил строке `unreadable` код 1250 → у неё `line_code_original_value = null` → для ядра она «шаблонная». Затем он назначает 1250 другой строке: + - если у первой нет сумм, ядро её удалит — потеря строки источника; это нарушает «первичка не мутируется» и «удаления строк нет»; + - если суммы есть — ложный 409 `LINE_EXISTS`. +- **Предложение.** Явно определить признак в спеке и DBML, например «создана правкой ⇔ `row_index IS NULL`». Миграция ставит `row_index = NULL` строкам с `line_code_original_value IS NULL`. Маппер 1.x и 2.0 всегда пишет `row_index`. Отдельно определить, что хранится в `line_code_original_value` у строк источника, например `line_code` из результата (`null` у статусов кроме `ok`). Сценарий назначения кода переписать без опоры на этот столбец. + +**K-2. Документы, принятые по 1.x, и мигрированные документы после удаления словаря §7 ядра.** +- **Где.** `specs/arm-core/spec.md` («Хранение результата», «Пересчёт после правки», «Словари ядра»), `specs/recognition-contract/spec.md` («Переход на v2.0»), `05-domain-rules.md` §7 «Ядро» и §9.3. +- **Что сказано.** Статус 1.x-документа считается «по прежнему правилу D-6». D-6 включает `checks_passed`, который ядро считает своим словарём (`load_check_rules` → `api/rules/statement_checks.yaml`), а этот словарь удаляется. В §7 одновременно сказано: «при приёме 1.x — прежним правилом D-6», «после правки — копией `arm_statements`». +- **Что не определено:** + - каким набором соотношений считать при `form_variant = null` (так у 1.x и у мигрированных документов); + - пишутся ли `document_issue` (`relation` / `soft`) для 1.x при загрузке; + - заполняет ли миграция `document_issue` для существующих форм; + - откуда `GET …/statement` и `POST …/confirm` (`CHECKS_NOT_PASSED`) берут нарушения у документа без записей `document_issue`. +- **Сценарий.** После миграции существующая форма `NEEDS_CHECK` с жёстким нарушением §7 не имеет записей `document_issue`. Если чтение идёт из `document_issue`, экран не показывает нарушение, а подтверждение проходит без акцепта. +- **Предложение.** Добавить требование: при `form_variant = null` пакет использует полный набор. При приёме 1.x и при миграции ядро один раз считает §7 копией пакета и пишет `document_issue` (`source = core`). Тогда `GET` и `confirm` читают только `document_issue`. Сценарии: «мигрированная форма с нарушением — `CHECKS_NOT_PASSED`» и «1.x-документ после PATCH». + +### Important + +**K-3. Сырые тексты и уверенности шапки негде хранить.** +- **Где.** Контракт `statementDocument.header.<поле>_text` и `<поле>_confidence` для 9 полей. Спека arm-core: `StatementOut` отдаёт «сырые тексты полей». DBML v5.19 `financial_statement_meta`. +- **Проблема.** В DBML есть только `inn_original_value`, `inn_confidence`, `report_date_original_value`, `units_original_value`. Нет `organization_name_text` / `_confidence`, `report_date_confidence`, `units_confidence` и `okud` / `okpo` / `okopf` / `okfs` / `address` `_text` / `_confidence`. Не сказано, что `*_original_value` теперь равно `<поле>_text`. Поля контракта молча теряются в ядре, экран не может показать подсказку у поля шапки. +- **Предложение.** Либо колонки в DBML (или `jsonb header_source`), либо явное правило «`*_original_value` := `<поле>_text`, уверенность хранится только у ИНН, остальное в БД не попадает». Плюс сценарий маппинга шапки. + +**K-4. На реальных OCR-фикстурах ни один документ не становится `done`, и спека этого не признаёт.** +Прогон приближённой реализации правил §11 на `fixtures/ocr/*.json`: +- **ББ 2025** (`aep_6`, «БО 1 кв 2025», «Бухгалтерская отчетность 1 кв.25»): §7 сходится, неподсвеченных значений нет. Но коды **1220 и 1520 законно повторены**: «НДС, начисленный по отгрузке», «Обязательство по аренде» — тот самый случай AURORA-1431. У одной из копий уверенность 50. Правило Р-16 «у неуверенной копии повтора — `needs_review`» даёт `needs_check` на **3 из 3**. +- **NAME_MISMATCH на тех же строках.** Ядро заблокирует `RECOGNIZED` ещё и `NAME_MISMATCH`. Добавить синонимы в словарь имён нельзя: загрузчик запрещает повтор кода (`arm-core/api/src/domain/statement_lines.py:47-49`), а S-4 сверяет только «по редакциям». +- **ОФР** (3 из 3): `2400`, `2500`, `2411` с уверенностью 66–71 участвуют только в мягких соотношениях, поэтому не покрыты → `needs_review` → `needs_check`. В §11.9 признаны только «25xx / 29xx», про `2400` / `2410` / `2411` / `2412` / `2460` ничего нет. +- **Регресса нет:** golden сегодня тоже `needs_check`. Но гайд фронта обещает «Форма из OCR теперь бывает `RECOGNIZED`». +- **Предложение.** + - (а) Разрешить повтор кода в упорядоченном перечне (fsbu4: 1220, 1220; 1520, 1520), чтобы структура подтверждала обе копии. Р-16 применять только к повтору, которого нет в перечне. + - (б) Словарь имён — список имён на код (синонимы). + - (в) Явно записать в §11.9: «2400 и строки 241x/2460 на уверенности». + - (г) Добавить сценарии на эти случаи. + +**K-5. Данные упорядоченного перечня кодов не заданы.** +- **Где.** `specs/statement-normalization` «Шаг 8 — подтверждение неуверенного кода», §11.9. +- **Проблема.** + - Источник описан как «коды шаблонов банка + коды печатных форм 1С (1105, 1530, 2420, 2460, 2930 и др.)»: перечня «и др.» нет ни в одном документе. + - Состав fsbu4 открыт (OQ-150). + - Перечни упрощённого варианта для fsbu4 не даны. + - Порядок для 1С-кодов (где стоят 1105, 1215, 2411/2412) не указан. + - Не сказано, допустимы ли повторы (см. K-4). +- От перечня зависят `needs_review` строки, статус и `CODE_NOT_IN_EDITION`. Исполнитель `arm-statements` не сможет написать golden-тест. +- **Предложение.** Положить перечни в `fixtures` / docs как данные (yaml), сделать ссылку из спеки, оформить OQ на состав. + +**K-6. Смысл `column_index` не определён.** +- **Проблема.** + - OCR отдаёт разреженные индексы (0, 1, 2, 4, 5), в примерах контракта колонки 0, 1. + - Не сказано, `columns[].column_index` — это индекс колонки исходной сетки или порядковый номер колонки значений. + - `COLUMN_SKIPPED` и `COLUMN_WITHOUT_DATE` несут `column_index`, а у пропущенной колонки нет `financial_statement_column`. `document_issue` хранит только `column_id`, поэтому привязку нельзя ни записать, ни показать. + - `line_refs` могут указывать на строки ролей `title` / `numbering`, которых нет среди `lines`. +- **Предложение.** Определить `column_index`. Для предупреждений о колонках вне `columns[]` добавить в `document_issue` поле `column_index` или правило «без привязки, у документа». + +**K-7. `TABLE_PARSE_ERROR` и `SHEET_OTHER` объявлены как предупреждения документа, но документа нет.** +- **Где.** Таблица §11.9 и шаг 8 спеки: уровень «error» / «info» без пометки «(файл)». В `scan-gateway/spec.md` — «предупреждение … документа-кандидата». +- **Проблема.** При исключении в разборе таблицы документа нет: сейчас шлюз пишет строку в предупреждения файла (`scan-gateway/api/src/normalizer/ocr/__init__.py:92-93`). Для листа «прочее» документа тоже нет. Исполнителю придётся выдумывать пустой `statementDocument` с неопределённым `document_type`. +- **Предложение.** Сделать оба предупреждения уровня файла с `locator` (`file:…;table:i`, `sheet:…`). `TABLE_PARSE_ERROR` уровня `error` тогда блокирует `RECOGNIZED` всего файла, как `JSON_FAILED`. + +**K-8. Окно выкладки противоречит требованию «Копия контракта в сервисе идентична источнику» — оно не изменено.** +- После слияния docs (шаг 2) `make check` tabular-parser и scan-gateway падает на тесте идентичности до их PR шага 5. Любой посторонний PR в эти сервисы в окне не пройдёт обязательную проверку. +- Ядру для маппера 1.x нужны схемы и примеры 1.x. В docs они заменены на месте (`$comment`: «Схема 1.2 — в истории git … и в моделях маппера 1.x»), а ядро проверяет примеры моделями. +- **Предложение.** MODIFIED-требование для окна перехода: в docs держать `recognition-result.v1.schema.json`, `recognition-result-message.v1.schema.json` и примеры 1.x до шага 6. Тест идентичности сервиса сверяет копию с той MAJOR, которую сервис пишет. Добавить сценарий. + +**K-9. `sync-statements` описан недостаточно.** +- **Не определено:** + - где лежит копия в хосте (`api/src/arm_statements`?), как её видят `mypy src`, `ruff` и `pytest`; + - где хранится маркер коммита и как он попадает в `service.statements_version` во время работы; + - что возвращает `--check`, когда копия просто отстала (у `sync-web.sh` это код 10); + - что делать без локального checkout `arm-statements`. +- Требование `quality-checks` («make check-full») не MODIFIED, хотя `check-full` теперь вызывает `sync-statements`. `dev/checks.md` тоже не дополнен. +- **Предложение.** Отдельное требование с кодами 0 / 10 / 20 и путями, MODIFIED `quality-checks`. + +**K-10. `DISCLOSURE` по `type_hint` выбрасывает таблицу без проверки кодов.** +- **Где.** Шаг 6: «DISCLOSURE по `type_hint` — шаги 2–7 не выполняются». +- **Сценарий.** ИС подписала таблицу баланса как «Пояснения к бухгалтерскому балансу», или лист Excel называется «Расшифровка», а внутри коды 1110–1700. Документ становится `DISCLOSURE` `done`, автоподтверждается, данных нет. `TYPE_CONFLICT` не срабатывает, принцип «ничего не выбрасывать молча» нарушен. +- **Предложение.** Шаг 2 (столбец кодов) выполнять всегда. Если большинство кодов в отрезке ББ или ОФР, тип определяется по кодам и выдаётся `TYPE_CONFLICT`. + +**K-11. Упрощённый ОФР `2400 = …` жёсткий (S-5), а в полном наборе то же `2400` мягкое из-за знака 2410 (OQ-12).** +Это противоречие в правилах, и образца нет. На упрощённой форме с доходом по налогу (`2410` отрицательный) модуль `abs_terms` даёт ложное нарушение и `needs_check`. Предложение — в S-5 ниже. + +**K-12. `NO_ORGANIZATION_NO_INN` снимается «правкой `organization_name`», но PATCH её не принимает.** +- §11.9 и спека arm-core («Предупреждения… — `document_issue`») говорят, что предупреждение снимается правкой `organization_name`. +- §9.5 п. 9 разрешает в PATCH только `inn`, `report_date`, `units`; схема `HeaderPatch` — `extra="forbid"` (`arm-core/api/src/schemas/statements.py:318-329`). +- **Предложение.** Либо добавить `organization_name` в PATCH (с аудитом), либо убрать его из правила снятия. + +### Minor + +**K-13.** У `document_issue` нет `codes`, а API обещает `checks` с `codes` (их пришлось бы разбирать из текста `relation`). Не сказано, какие `code` и `severity` пишутся для записей видов `relation` и `soft`. Предложение: колонка `codes jsonb` и правило «relation → error, soft → info, code = `RELATION_FAILED` / `RELATION_SOFT`». + +**K-14.** Не определён интерфейс функции статуса. Это одна функция для сервиса (по `FormCandidate`) и для ядра (по строкам БД): какие входы, откуда ядро берёт «раскладка найдена». Также неоднозначно «содержимое» строки данных: считается ли неразобранная ячейка кода с цифрами без значений содержимым. Пример — `aep_6`, строка `"44 30\nI |"` при пустых значениях. + +**K-15.** Строка нумерации (S-13) строгая. Сбой OCR в нумерации («1 2 З 4») превращает её в строку `unreadable` с «суммами» 3, 4, 5 (код «2» не код, но с цифрой): документ `needs_check` и `LINES_UNRESOLVED` до ручной отметки. В Excel mezheninovskaya в столбце кодов строк нумерации стоит «2» (строки 15, 37, 49) — правило должно их ловить. Нужны сценарии с двойником и без первой ячейки. + +**K-16.** `LINES_UNRESOLVED` считает только `amount` не `null`. Строка `unreadable` / `missing`, у которой есть только `string_value` (мусор `211(1 |` в `aep_3`), подтверждение не блокирует, хотя сумма потеряна. + +**K-17.** У бланка 66н только с относительными подписями неуверенная дата отчёта никогда не подтверждается (нет абсолютных дат колонок) → всегда `REPORT_DATE_UNCERTAIN` `error`. Это следует признать в §11.9. + +**K-18.** Разнобой формулировок: +- в шаге 2 спеки `period_end`, а в контракте и DBML `period_date`; +- «DISCLOSURE без `columns` / `lines`», а схема требует пустые массивы; +- статус ОСВ: «ведомость разобрана» (контракт, спека tabular-parser) против «контрольные суммы §1.4 сошлись» (§1.6, S-22); +- в §9.5 п. 9 осталось «Код и название строки … не правятся» рядом с блоком «С v2.0»; +- ядро сортирует «по возрастанию кода» (как сейчас, `schemas/statements.py:221`), а спека называет это «как на бумаге»; +- 409 `LINE_EXISTS` совпадает по имени с текущей константой-сообщением `LINE_EXISTS` у 422 `UNKNOWN_CELL` (`services/statements.py:49`, `:239-240`). + +**K-19.** Откат миграции на БД с данными v2.0 (повторы кода, пустые коды) невозможен из-за возврата уникального `(meta_id, line_code)`. Надо оговорить: откат гарантирован только на пустой или совместимой БД. + +**K-20.** «Того же периода» для `DUPLICATE_FORM` и `RECOGNIZED`: по `period_year` / `period_quarter` или по точной дате — не сказано. + +**K-21.** Для 1.x не сказано, где хранится `LEGACY_WARNING` (в `document_issue` или в `metrics`). У 1.x-заданий `statements_version = null`, поэтому любой PATCH будет порождать `STATEMENTS_VERSION_MISMATCH`. + +**K-22.** Словаря единиц нет в перечне содержимого пакета. В §11.2 адаптер собирает ОКЕИ, а в `header` контракта поля `okei` нет (в DBML `okei` есть). + +**K-23.** Пробелы PATCH: +- нет снятия подтверждения; +- не определён смысл `line_status: "ok"` (кроме 422); +- не сказано, сбрасывает ли акцепт подтверждение без правки («любой правкой»); +- не сказано, допустим ли `columns[].period_date = null`; +- нет пути «`detail` → обратно в `missing`». + +**K-24.** Ядро не проверяет согласованность `document.status = done` из результата. Если сервис ошибся и прислал `done` со строками `unreadable`, автоподтверждение обойдёт `LINES_UNRESOLVED`. Дешёвая страховка: `done` ⇒ нет `unreadable` / `missing` с суммой и нет `needs_review`. + +**K-25 (на данных Excel, информационно).** Прогнал CLI tabular-parser на всех 25 книгах `fixtures/statements-xlsx` и пересчитал §7 по правилу «посчитанного» соотношения. +- `done`-документы манифеста (mezheninovskaya ББ/ОФР, НПЗ ББ/ОФР, unagrande ОФР «Лист 1» ×2) новых жёстких нарушений не получают. Их строки без кода уходят в `detail` («Авансы по инвестиционной деятельности», «Реализация иного имущества…»), а строки шапки выше строки заголовка — в `title`. +- Меняются только уже `needs_check` документы: страницы 2 ББ («Б.Б. Лист 2», «ББ. Лист 2» ×2, `st05`) получают `1600 = 1700` (пустая цель), страницы 1 «только актив» — то же нарушение как «пустые члены». +- Значит, `failed_relations` в `tabular-parser/api/tests/statements_manifest.yaml` надо обновить. Спека этого не упоминает. + +## 3. Решения писателя S-1…S-25 + +| S-n | Суть | Оценка | Обоснование | +|---|---|---|---| +| S-1 | Дельта к существующей `recognition-contract` | согласен | Способность уже есть, MODIFIED корректны, validate проходит. | +| S-2 | Конверт тоже v2.0 | согласен | Строка → объект в `metrics.warnings` — смена типа, это MAJOR. Ядро и так принимает 1.x и 2.0, включая ack. Но см. K-8 про копии схем. | +| S-3 | Диапазоны 3100–3699, 4100–4499, 5000–5999, 6100–6499 | согласен | На фикстурах годы и суммы кодами не становятся; правится словарём. | +| S-4 | Правило совпадения наименований | согласен с оговоркой | На 3 из 3 реальных ББ 2025 законные повторы 1220 и 1520 дают `NAME_MISMATCH`, а добавить синонимы в словарь имён нельзя (K-4). Вхождение подстроки от 5 символов слишком широко для итогов: «итого по разделу i» входит в «…ii» и «…iii», так что подмену 1100 ↔ 1200 ↔ 1300 по имени не поймать, держится только на структуре. Предлагаю синонимы на код и исключать из правила вхождения строки «итого по разделу». | +| S-5 | Формулы упрощённого набора, все жёсткие | не согласен (частично) | Формулы ББ и ОФР по прил. 5 верны. Но `2400` в полном наборе мягкое из-за знака 2410 (OQ-12). Сценарий: упрощённый ОФР с доходом по налогу, `2410` отрицательный → модуль `abs_terms` → ложное жёсткое нарушение и `needs_check`. Предлагаю ББ оставить жёсткими, ОФР `2400` сделать мягким до образца (OQ-166). | +| S-6 | Словари подписей «код» и «наименование» | согласен | Совпадает с фикстурами («Код», «Наименование показателя»); пополняется данными. | +| S-7 | Прочерк — пусто, `is_recognized = true` | согласен с оговоркой | Совпадает с Excel (литерал `[=0]"-"` → нет значения). Но OCR-тире на месте потерянной цифры с уверенностью 100 ловит только §7, а на строках только с мягкими проверками (2400, 25xx) не ловится ничем. Отменяет допущение OQ-148 — нужно явное утверждение. | +| S-8 | Каталог кодов; `code` — строка по шаблону; `VALUE_OUT_OF_RANGE` — `info` | согласен | Такое значение и так `needs_review` (`amount = null`, не покрывается), так что `info` статус не ослабляет. | +| S-9 | `locator` у предупреждения файла | согласен | Нужен ещё для `TABLE_PARSE_ERROR` и `SHEET_OTHER` (K-7). | +| S-10 | `needs_review_fields` — только `report_date` | согласен с оговоркой | Логика верна (НПЗ ОФР без ИНН остаётся `done`), но сырые тексты и уверенности ИНН и организации негде хранить (K-3). Меняет смысл «`needs_review` у полей шапки». | +| S-11 | Значения `period_kind`, 29.02 → 28.02 | согласен | Покрывает бланк 66н. В DBML это `varchar`, в контракте enum: новое значение — MINOR, «сначала потребители». | +| S-12 | Ожидаемые даты: + 31.12 прошлого и позапрошлого года | согласен | Без этого любой квартальный ББ с неуверенной подписью давал бы `error`. Но это расширение набора дизайна — смысл меняется, нужно утверждение. | +| S-13 | Строка нумерации — ≥ 3 целых подряд с 1 | согласен с оговоркой | Сбой OCR («1 2 З 4», пропущенная «1») превращает её в `unreadable` с «суммами» и блокирует подтверждение (K-15). Предлагаю двойники внутри детектора и допуск одного пропуска. | +| S-14 | Повторная шапка: подпись кода или ≥ 2 совпавших ячейки | согласен | На фикстурах повторная шапка всегда с «Код». | +| S-15 | `row_index` с 0, `ref`, разлепленные `#k` с 1 | согласен с оговоркой | Не сказано, как экранируются имена листов с `!` и пробелами в Excel-`ref` (`'Лист 1'!C14`?). В примере `Бухгалтерский баланс. Лист 1!C14` — без кавычек. | +| S-16 | Уверенность кода с заменой двойника ≤ 99 | согласен | Позволяет ядру после правки признать код неуверенным по одному сохранённому полю. | +| S-17 | Формат `lines` в PATCH и коды 422 | согласен с оговоркой | Не определены `line_status: "ok"`, снятие подтверждения, «`detail` → обратно» (K-23). 409 `LINE_EXISTS` совпадает по имени с текущей константой-сообщением (K-18). | +| S-18 | Детали 409 | согласен | Достаточно для экрана. | +| S-19 | Имена DBML, `line_ids` в `jsonb`, без `confirmed_by` у строки | согласен с оговоркой | `jsonb` без FK допустим, но шаблонная строка, удаляемая при назначении кода, оставит висячий `line_id` в предупреждениях. Не хватает `codes` и `column_index` (K-6, K-13). | +| S-20 | `related_group.crm_id` уникален, 409 `CRM_ID_EXISTS` | согласен | Как у ГК и ЮЛ; существующие данные — `null`, конфликтов нет. Но это новое ограничение — нужно утверждение. | +| S-21 | Значения по умолчанию при приёме 1.x | согласен с оговоркой | Не сказано, где хранится `LEGACY_WARNING`, каким набором §7 считать 1.x и есть ли для них `document_issue` (K-2, K-21). | +| S-22 | Статус ОСВ по контрольным суммам §1.4 листа | согласен с оговоркой | По сути верно (паритет с конвертом), но контракт и спека tabular-parser говорят «разобрана». Нужна одна формулировка (K-18). | +| S-23 | `statements_version` обязателен при `statementDocument` | согласен | Схема это проверяет; книга только с ОСВ без него валидна. | +| S-24 | Редакция перечня: `fsbu4` после 2024-12-31 | согласен | То же правило, что `edition_for` ядра; без даты — обе редакции. | +| S-25 | Не конец квартала → `NEEDS_CHECK`, ручное подтверждение — 409 `PERIOD_REQUIRED` | согласен | Снимает противоречие «`RECOGNIZED`, но не подтверждаемый» и автоподтверждения. Это пересмотр оговорки дизайна — нужно явное утверждение. | + +**Решения, которые меняют смысл, а не уточняют значение (утвердить явно):** +- **S-4** — состав условия `RECOGNIZED` и правило совпадения имён; +- **S-5** — жёсткость упрощённых формул; +- **S-7** — закрывает OQ-148 противоположно прежнему допущению; +- **S-10** — ИНН и организация не входят в `needs_review`; +- **S-12** — расширенный набор ожидаемых дат; +- **S-20** — новое ограничение уникальности; +- **S-22** — определение статуса ОСВ; +- **S-25** — пересмотр оговорки дизайна про квартал; +- **S-13 и S-14** — эвристики, от которых прямо зависят статусы строк. + +## 4. Что проверил и признал согласованным + +**Согласовано:** +- **Формат OpenSpec.** `openspec validate … --strict` проходит. Имена MODIFIED совпадают с `openspec/specs/recognition-contract/spec.md`. В MODIFIED «Файл результата» сокращено описание `extra` у ОСВ (в живой спеке после архивации потеряется формулировка «заголовок колонки → строка или null») — мелочь. +- **Контракт v2.0 ↔ спека шагов.** + - `line_code` равен `null` тогда и только тогда, когда статус не `ok`; `parent_ref` обязателен у `detail`. + - `checks` есть у ББ/ОФР со строками и отсутствует без строк; `duplicate_line_codes` удалён. + - `statements_version` условно обязателен. + - Перечни `period_kind`, `form_variant`, `severity` совпадают со спекой и §11. +- **Примеры.** Все 10 валидны с проверкой форматов. Арифметику обоих примеров форм пересчитал вручную: + - покрытие `"42831 1"` соотношением 1200 и разлепленные 1370/1300 сходятся; + - в `result-statement-excel.json` мягкое `1110 = 11101` и жёсткое `2200` с пустой целью соответствуют правилам. +- **§7, §11, §9.3, §9.5 ↔ спека.** + - Порядок 409 одинаков в спеке arm-core, §9.5 п. 4 и гайде фронта. + - Коды 422 и 409 PATCH одинаковы. + - Уровни предупреждений в таблице шага 8 и §11.9 совпадают. + - Условие `RECOGNIZED` одинаково в спеке и §9.3. +- **DBML v5.19 ↔ спека** по строкам, значениям, колонкам, `document_issue`, `file_ordinal`, `crm_id`, частичным индексам и заполнению при миграции — кроме K-1, K-3, K-6, K-13. +- **Правила на фикстурах.** + - Двойники в кодах: «I1О0» → 1100, «| 140» → 1140. + - «44 30\nI |» `aep_6` не разлепляется. + - «1500\n1700» разлепляется. + - Excel `done` остаются `done` (K-25). + +**Файлы и символы, которые смотрел:** +- **Change:** `openspec/changes/unified-statement-normalization/proposal.md`, `specs/{statement-normalization,recognition-contract,tabular-recognition-service,scan-gateway,arm-core,arm-web}/spec.md`. +- **Живые спеки:** `openspec/specs/{recognition-contract,quality-checks}/spec.md`. +- **Контракт:** `contracts/recognition-result.schema.json`, `contracts/recognition-result-message.schema.json`, `contracts/examples/*`, `scripts/validate_contracts.py`. +- **Правила и вопросы:** `context/05-domain-rules.md` §1.6, §7, §9.2–§9.5, §11–§12 и диф от `43b4b80`; `context/07-open-questions.md` (OQ-12, 36, 139, 141, 142, 145–150, 152, 156, 157, 165–167). +- **Модель:** `model/data-model-v5.19.dbml` (диф с v5.18), `model/entities-v5.19.md`. +- **Прочее docs:** `dev/frontend-api-guide.md` §5.6.1, `dev/checks.md`, `Makefile`, `scripts/sync-web.sh`. +- **Решения писателя:** `/private/tmp/claude-501/-Users-andrey-epifancev-projects-awp-ca/12de9a9a-5665-41d0-beea-0651270d88ec/scratchpad/spec-review/writer-decisions.md`. +- **Фикстуры:** + - `fixtures/ocr/{aep_3,aep_6,БО 1 кв 2025,Бухгалтерская отчетность 1 кв.25}.json` и `expected_normalize/*`; + - `fixtures/statements-xlsx/**` (25 книг через `python -m src.parser.cli` tabular-parser); + - `tabular-parser/api/tests/statements_manifest.yaml`. +- **arm-core:** `api/src/services/statements.py` (`_apply_cells`, `_resolve_cells`, `build_out`, `patch`, `LINE_EXISTS`), `api/src/schemas/statements.py` (`line_origin`, `_lines_out`, `HeaderPatch`), `api/src/repositories/statements.py` (`add_line`, `value_flags`), `api/src/domain/statement_lines.py` (`_form_lines`), `api/src/domain/statement_mapping.py` (`values_ok`, `statement_status`), `api/src/models/statements.py`, `api/rules/statement_lines.yaml`, `api/contracts/`. +- **tabular-parser:** `api/src/parser/statements/checks.py` (`_holds`, `run_statement_checks`), `api/rules/statement_checks.yaml`, `number_format._literal_result`. +- **scan-gateway:** `api/src/normalizer/ocr/__init__.py` (обработка исключения таблицы), `tests/unit/normalizer/test_dates.py::test_parses_real_forms` (существует). + +**Скрипты прогона** (только для воспроизведения, в репозитории их нет): +- `/private/tmp/claude-501/-Users-andrey-epifancev-projects-awp-ca/12de9a9a-5665-41d0-beea-0651270d88ec/scratchpad/rev/scripts/sim_excel.py` +- `/private/tmp/claude-501/-Users-andrey-epifancev-projects-awp-ca/12de9a9a-5665-41d0-beea-0651270d88ec/scratchpad/rev/scripts/ocr_sim.py` +- `/private/tmp/claude-501/-Users-andrey-epifancev-projects-awp-ca/12de9a9a-5665-41d0-beea-0651270d88ec/scratchpad/rev/scripts/ocr_full.py` +- `/private/tmp/claude-501/-Users-andrey-epifancev-projects-awp-ca/12de9a9a-5665-41d0-beea-0651270d88ec/scratchpad/rev/scripts/scan_xls.py` + +OCR-прогон — приближённая модель правил §11, а не реализация: цифры K-4 показывают направление, но не точный результат будущего кода. \ No newline at end of file diff --git a/openspec/changes/unified-statement-normalization/specs/arm-core/spec.md b/openspec/changes/unified-statement-normalization/specs/arm-core/spec.md new file mode 100644 index 0000000..56df942 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/specs/arm-core/spec.md @@ -0,0 +1,530 @@ +## ADDED Requirements + +### Requirement: Хранение результата формы v2.0 (DBML v5.19) +Маппер результата `2.0` SHALL писать документ формы как пришёл, без повторной дедупликации колонок, кодов и значений: +каждая строка результата — `financial_statement_line` (`row_index`, `source_ref` = `ref`, `line_status`, `line_code`, +`line_code_original_value` = `line_code` результата, `line_code_text`, `line_code_confidence`, `line_name`, +`line_name_confidence`, `parent_row_index` — `row_index` строки с `ref = parent_ref`, `needs_review`, `is_confirmed = +false`); каждая пара «строка × колонка» — одна `financial_statement_value` (`amount`, `string_value`, `source_text` = +`values[].text`, `confidence`, `is_recognized`, `needs_review`; значения, которого нет в результате, — пустая запись с +`needs_review = false`); колонки — `financial_statement_column` с `period_kind`, `label_confidence`; шапка — +`financial_statement_meta` с `form_variant`, `needs_review_fields` и `header_source` (раздел «Шапка формы»); +предупреждения и нарушения документа — `document_issue` (источник `service`); версия общего кода — +`recognition_job.statements_version`. Уникальность `(meta_id, line_code)` снята; `(meta_id, source_ref)` уникален там, +где `source_ref` не `null`. Каждый `BALANCE` / `INCOME` результата SHALL становиться отдельным `document`; вторая +форма того же типа в файле хранится (Р-10); ОСВ по счёту, `DISCLOSURE` и `OTHER` — по-прежнему один документ на тип (и +счёт) на файл. **Каждый** создаваемый `document` (ОСВ, формы, `DISCLOSURE`, `OTHER`) при приёме результата любого +`MAJOR` (мапперы `1.x` и `2.0`) SHALL получать `file_ordinal` = индекс документа в `documents[]` результата (с 0); +документ, который не создаётся (второй лист ОСВ с тем же счётом, второй `DISCLOSURE`), номер не занимает, но и не +сдвигает остальные — номера уникальны в файле и могут идти с пропусками. + +#### Scenario: Книга из двух ОСВ +- **WHEN** результат содержит две ОСВ по счетам 60 и 62 (`documents[0]`, `documents[1]`), принятый маппером `1.x` или `2.0` +- **THEN** два документа с `file_ordinal` 0 и 1; уникальность `(stored_file_id, file_ordinal)` не нарушена, файл не `ERROR` + +#### Scenario: PDF с ББ и ОФР по 1.x +- **WHEN** результат `1.2` содержит `BALANCE` и `INCOME` +- **THEN** документы с `file_ordinal` 0 и 1 + +#### Scenario: Повтор кода сохраняется +- **WHEN** в результате две строки `ok` с кодом 2410 (пустая и с `(649)`) +- **THEN** в БД две строки 2410 с разными `row_index` и `source_ref`, обе со своими значениями + +#### Scenario: Строка без кода +- **WHEN** в результате строка `unreadable` с `line_code_text = "ИЧ,250"` и суммой +- **THEN** строка хранится с `line_code = null`, `line_code_original_value = null`, `line_status = unreadable`, + `line_code_text = "ИЧ,250"` и `row_index` из результата + +#### Scenario: Два баланса одного периода в одном файле +- **WHEN** результат содержит два `BALANCE` с одной парой `period_year`, `period_quarter` (лист-продолжение другого + типа или двойная печать) +- **THEN** создаются два документа с разными `file_ordinal`; ни один не автоподтверждается (Р-18) + +#### Scenario: Два баланса разных периодов в одном файле +- **WHEN** результат содержит `BALANCE` на 30.09.2025 и `BALANCE` на 31.12.2024 +- **THEN** два документа с разными `file_ordinal`; каждый получает статус по своим условиям, правило Р-18 их не касается + +### Requirement: Признак строки, созданной правкой +Строка формы SHALL считаться созданной правкой аналитика (шаблонной, `origin = template`) тогда и только тогда, когда +`row_index IS NULL`. Маппер `1.x` и маппер `2.0` SHALL всегда писать `row_index` (у `1.x` — по порядку строк результата с +0), поэтому у строки из источника он не `null`. `line_code_original_value` SHALL хранить код строки из результата как +пришёл (`null` у `unreadable` / `missing` / `detail` и у строк, созданных правкой) и правками кода MUST NOT меняться. +Признак `line_code_original_value IS NULL` для `origin` MUST NOT использоваться. + +#### Scenario: Строка источника без кода — не шаблонная +- **WHEN** у строки `unreadable` из результата `line_code_original_value = null` и `row_index = 7` +- **THEN** `origin = source`; правила шаблонных строк (удаление без сумм, 409) к ней не применяются + +#### Scenario: Назначение одного кода двум строкам источника +- **WHEN** аналитик назначил код 1250 строке `unreadable` (`row_index = 7`, без сумм), а затем тот же код — строке + `missing` (`row_index = 2`) с суммой +- **THEN** обе строки `ok` с кодом 1250, ни одна не удалена, 409 нет (повтор кода законен, Р-4) + +### Requirement: Шапка формы — сырой текст и уверенность +Маппер `2.0` SHALL писать в `financial_statement_meta.header_source` объект `{"<поле>": {"text": <поле>_text, +"confidence": <поле>_confidence}}` для всех полей шапки результата (`organization_name`, `inn`, `report_date`, `units`, +`okud`, `okpo`, `okopf`, `okfs`, `okei`, `address`); разобранные значения — в одноимённые колонки; `*_original_value` — +как прежде: `inn_original_value = inn`, `report_date_original_value = report_date_text`, `units_original_value = +units_text`; `organization_name_original_value = organization_name`, `inn_confidence = inn_confidence`. +`header_source` и `*_original_value` правками MUST NOT меняться. Маппер `1.x` заполняет `header_source` из полей шапки +1.x. `StatementOut.header.source` SHALL отдавать `header_source`. + +#### Scenario: Подсказка у поля шапки +- **WHEN** в результате `organization_name_text = "ООО «Ромашкa»"`, `organization_name_confidence = 67` +- **THEN** `header_source.organization_name = {"text": "ООО «Ромашкa»", "confidence": 67}`; экран показывает их в подсказке + у поля + +### Requirement: Агрегат формы по коду +Все чтения формы «по коду» SHALL идти через одну чистую функцию «агрегат формы по коду» без хранения (DECISION-5): +для кода и колонки — сумма `amount` строк `ok` с этим кодом (`null`, если ни одной непустой), список `line_id`, +справочное имя. На неё переводятся межформенная проверка `1370 / 2400`, суммы формы для консолидации и выгрузок, +`parent_code` подстрок, раскладка строк на экране, правка ячеек по `line_code`, метрики `fields_*`. Строки `detail`, +`unreadable`, `missing` в агрегат, КФО и снимок ЕХ MUST NOT попадать. + +#### Scenario: Межформенная проверка по агрегату +- **WHEN** в ББ две строки 1370 (сумма 500 и 300 на отчётную дату) +- **THEN** межформенная проверка берёт 1370 = 800 + +### Requirement: Условие `RECOGNIZED` при приёме 2.0 +При приёме `2.0` статус документа формы SHALL быть `RECOGNIZED` ⇔ `document.status = done` и он согласован с +содержимым документа ∧ в `metrics.warnings` файла нет предупреждений уровня `error` ∧ `counterparty_check ≠ MISMATCH` ∧ +период — конец квартала (`period_year` и `period_quarter` определены) ∧ у каждой строки `ok` с неуверенным кодом +(`line_code_confidence < 100`) распознанное наименование совпало со справочным по коду ∧ в файле нет второй формы того +же типа и периода (та же пара `period_year`, `period_quarter`; формы без квартала не считаются); иначе `NEEDS_CHECK`. +**Согласованность:** ядро SHALL вызывать общую функцию статуса пакета (`statement-normalization`, «Шаг 8 — статус +документа и конверта») по входу, собранному из результата: `layout_found` — `lines[]` непуст, `has_organization_or_inn` +и `period_known` — по шапке и колонкам результата, строки и значения с их `needs_review` (значения `detail` функция не +учитывает), число `checks.relations_failed`, `accepted = false`, уровни `warnings[]` документа. `done` согласован, если +функция тоже даёт `done`; иначе ядро считает документ `needs_check` и пишет `document_issue` +`RESULT_STATUS_INCONSISTENT` (`info`, источник `core`). Это вызов той же функции, а не пересчёт §7 и `needs_review`. Статус конверта и `needs_check` соседнего документа в условие +MUST NOT входить (Р-19). Проверки §7, `needs_review` и предупреждения сервиса при загрузке MUST NOT пересчитываться. + +**Совпадение наименований:** обе строки приводятся к нижнему регистру, `ё` → `е`, всё, кроме букв и цифр, — пробел, +пробелы схлопываются, крайние пробелы обрезаются (`strip`); совпало, если строки равны, или более короткая (не короче 5 +символов) входит в более длинную, или отношение сходства `difflib.SequenceMatcher` ≥ 0,8; сравнение — с **любым** +справочным именем кода (у кода может быть несколько имён — синонимы и законные повторы, Р-20) в любой редакции словаря +ядра. Если хотя бы одно из сравниваемых имён начинается с «итого по разделу», сравниваются **номера раздела**: номер — +начальная часть первого слова после фразы из букв `i`, `v`, `x` (латиница; кириллические «і», «х» приводятся к ним), +остальное в слове отбрасывается («v1» → V, «v» → V); совпало ⇔ номера равны; номер не выделен хотя бы у одного имени — +совпадение только при равенстве строк целиком. Кода нет в словаре имён или наименование пусто — не совпало. +Несовпадение — `document_issue` `NAME_MISMATCH` (`info`, источник `core`, строка). `RECOGNIZED` SHALL, как и прежде, +автоподтверждаться (OQ-157). + +#### Scenario: Ячейка соседнего документа +- **WHEN** ББ `done`, ОФР того же файла `needs_check`, предупреждений файла уровня `error` нет, ИНН совпал, период — + 30.09.2025 +- **THEN** ББ `RECOGNIZED` и автоподтверждается, ОФР — `NEEDS_CHECK` + +#### Scenario: Потерянный json +- **WHEN** все документы `done`, но в `metrics.warnings` файла `JSON_FAILED` (или `TABLE_PARSE_ERROR`) уровня `error` +- **THEN** все документы формы файла `NEEDS_CHECK` + +#### Scenario: Подмена неуверенного кода на соседа +- **WHEN** строка с кодом 1120 (уверенность 50) и наименованием «Нематериальные активы» +- **THEN** наименование не совпало со справочным «Результаты исследований и разработок», документ `NEEDS_CHECK`, + `NAME_MISMATCH` у строки + +#### Scenario: Законный повтор с другим именем +- **WHEN** строка 1220 (уверенность 50) с наименованием «НДС, начисленный по отгрузке», а в словаре имён `fsbu4` у 1220 + есть это имя вторым +- **THEN** наименование совпало, `NAME_MISMATCH` нет + +#### Scenario: Подмена итога раздела +- **WHEN** строка с кодом 1200 (уверенность 50) и наименованием «Итого по разделу I» +- **THEN** номер раздела I не равен II у справочного «Итого по разделу II», `NAME_MISMATCH` + +#### Scenario: Хвост OCR после номера раздела (Счастливочная, РБ) +- **WHEN** строка 1500 (уверенность 56, разлепленная) с наименованием «Итого по разделу V1», или строка с наименованием + «Итого по разделу V /» +- **THEN** номер раздела V равен номеру справочного «Итого по разделу V», наименование совпало, `NAME_MISMATCH` нет + +#### Scenario: Сервис прислал done при неразобранной строке без значений +- **WHEN** документ пришёл `done`, но в нём строка `unreadable` без значений +- **THEN** функция статуса даёт `needs_check` (не все строки `ok` / `detail`), документ `NEEDS_CHECK`, + `RESULT_STATUS_INCONSISTENT`; автоподтверждения нет + +#### Scenario: Сервис ошибся со статусом +- **WHEN** документ пришёл `done`, но в нём строка `unreadable` с суммой +- **THEN** документ `NEEDS_CHECK`, `RESULT_STATUS_INCONSISTENT` у документа; автоподтверждения нет + +#### Scenario: Период не конец квартала +- **WHEN** документ `done` с датой отчёта 15.08.2025 +- **THEN** квартал не определён, документ `NEEDS_CHECK` (как в D-6) и не автоподтверждается; ручное подтверждение — + 409 `PERIOD_REQUIRED`, пока аналитик не поправит дату отчёта + +### Requirement: Предупреждения и нарушения документа — `document_issue` +Ядро SHALL хранить предупреждения и нарушения документа в `document_issue`: вид (`warning` | `relation` | `soft`), код, +уровень (`error` | `info`), сообщение, список `line_id`, `column_id` (колонка формы) или `column_index` (столбец +предупреждения, которого нет среди колонок формы), поле шапки, источник (`service` | `core`); для нарушений — `codes` +(коды цели и членов), текст соотношения, `expected`, `actual`. Нарушение из `checks.relations_failed` — `kind = +relation`, `code = RELATION_FAILED`, `severity = error`; из `soft_relations_failed` — `kind = soft`, `code = +RELATION_SOFT`, `severity = info`; `line_ids` — все строки `ok` кодов `codes` в документе. При загрузке пишутся +предупреждения и `checks` результата (источник `service`) и проверки ядра уровня `info` (источник `core`): +`CODE_NOT_IN_DICTIONARY` (кода нет в словаре имён редакции документа), `CODE_NOT_IN_EDITION` (код не из перечня +редакции документа и его варианта — по копии перечня `arm_statements`), `NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT`. +**Редакция документа** для этих проверок — `edition` точки входа пакета для пересчёта (`statement-normalization`): +редакция с лучшей подпоследовательностью кодов строк `ok` документа, при равенстве — по эффективной дате; так бланк 66н, +сданный в 2025 году, проверяется по `66n`, а не по дате. Поле `edition` ответа `GET …/statement` и выбор шаблонных +строк по-прежнему — по дате периода (§7 «Полный состав формы»). `document_issue` ведётся у **любого** документа файла: у +ОСВ — предупреждения `osvDocument.warnings` результата `2.0` (вид `warning`, источник `service`, `OSV_*`, `info`). При перезагрузке файла записи документа +удаляются вместе с ним. При пересчёте после правки записи `relation` и `soft` заменяются целиком независимо от источника; +записи `warning` источника `service` сохраняются, кроме снимаемых правкой: правка `header.report_date` (в том числе +подтверждение тем же значением) снимает `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, `REPORT_DATE_UNCERTAIN`; правка +`header.inn` или `header.organization_name` — `NO_ORGANIZATION_NO_INN`; правка `columns[].period_date` — +`COLUMN_WITHOUT_DATE` и `COLUMN_DATE_UNCERTAIN` этой колонки; прочие `error` сервиса снимаются только перезагрузкой +файла. При удалении шаблонной строки её `id` SHALL убираться из `line_ids` всех записей документа. `GET …/statement` и +`POST …/confirm` SHALL читать нарушения §7 только из `document_issue`. + +#### Scenario: Дата колонке +- **WHEN** у колонки предупреждение `COLUMN_WITHOUT_DATE`, аналитик ставит ей `period_date` через PATCH +- **THEN** предупреждение удаляется, статус пересчитывается + +#### Scenario: Неснимаемое предупреждение +- **WHEN** у документа `SEGMENT_COLUMNS_MISMATCH`, аналитик правит значения +- **THEN** предупреждение остаётся; документ можно подтвердить вручную (оно подтверждение не блокирует) + +#### Scenario: Бланк 66н в 2025 году без ложного info +- **WHEN** форма Межениновской за 3 кв. 2025 на бланке 66н с кодом 1120 (в перечне `fsbu4` его нет) +- **THEN** редакция документа для проверок ядра — `66n` (подпоследовательность длиннее), `CODE_NOT_IN_EDITION` у 1120 нет + +#### Scenario: Пропущенная колонка +- **WHEN** в результате предупреждение `COLUMN_SKIPPED` с `column_index = 5`, колонки 5 среди `columns[]` нет +- **THEN** запись `document_issue` с `column_index = 5` и `column_id = null`; экран показывает её у документа + +### Requirement: Формы результата 1.x — §7 при приёме +Для формы, принятой по результату `1.x` (окно перехода после выкладки ядра; `checks` контракта 2.0 у неё нет), ядро +SHALL **один раз при приёме** посчитать §7 точкой входа пакета для пересчёта (`statement-normalization`; вариант формы — +по структуре кодов, как у 2.0) по суммам результата и записать нарушения в `document_issue` (источник `core`, виды +`relation` / `soft`, как в требовании о `document_issue`) и `form_variant` в `financial_statement_meta`. Условие D-6 +«проверки сошлись» для `1.x` SHALL браться из этого расчёта; `needs_review` значения при приёме `1.x` = **НЕ** +`values_ok` (`values_ok` — `is_recognized` не `false` и `confidence` равна 100 или пуста), `needs_review` строки — +`false`. Предупреждения маппера `1.x` по документу (повтор кода, значение вне колонок) — `document_issue` +`MAPPING_WARNING` (`info`, источник `core`); строки `metrics.warnings` результата `1.x` — в +`recognition_job.metrics.warnings` структурами `{code: "LEGACY_WARNING", severity: "info", message}`. После правки такая +форма пересчитывается общим кодом, как форма 2.0; `MAPPING_WARNING` и `LEGACY_WARNING` статус не держат. Формы, +существовавшие в БД до миграции v5.19, §7 не пересчитываются (требования «Миграция DBML v5.19» и «Очистка форм перед +выкладкой»). + +#### Scenario: Полярность needs_review при приёме 1.x +- **WHEN** в результате `1.2` у значения `confidence = 100`, `is_recognized = true`, у другого — `confidence = 80` +- **THEN** у первого `needs_review = false`, у второго — `true` + +#### Scenario: Форма 1.x с нарушением +- **WHEN** результат `1.2` содержит ББ с несошедшимся `1600 = 1700` +- **THEN** у формы запись `document_issue` `relation` `RELATION_FAILED` с `codes = ["1600", "1700"]` (источник `core`), + документ `NEEDS_CHECK`; `POST …/confirm` без акцепта — 409 `CHECKS_NOT_PASSED` + +#### Scenario: 1.x-документ после PATCH +- **WHEN** форма принята по результату `1.2`, аналитик правит сумму 1230 так, что `1200 = 1210 + … + 1260` сходится +- **THEN** ядро пересчитывает §7 и статус общим кодом, запись `relation` по 1200 заменена (её больше нет), статус + пересчитан; `STATEMENTS_VERSION_MISMATCH` не пишется (у задания 1.x версии нет) + +### Requirement: Пересчёт после правки +После каждого PATCH формы ядро SHALL пересчитывать `checks`, `needs_review`, `form_variant`, родителей строк `detail` +и статус точкой входа пакета для пересчёта по сохранённым данным (`statement-normalization`; конвенция знака — по +`source_text`), с правилами: `is_modified` или `is_confirmed` значения ⇒ `needs_review = false`; `line_code_modified` или +`is_confirmed` строки ⇒ `needs_review` строки = `false`; значения строк `detail` — `needs_review = false` (Р-23), у +строки, выведенной из `detail` назначением кода, `needs_review` её значений считается по общему правилу; акцепт ⇒ +жёсткие нарушения не учитываются; правленые ячейки в голосование знака не входят. Результат пишется: `needs_review` +строк и значений, `form_variant`, `parent_row_index` всех строк `detail` (ближайшая строка `ok` выше по `row_index`; +нет такой — `null`, строка остаётся `detail`), записи `document_issue` видов `relation` / `soft`, статус. Отличие `statements_version` задания от версии копии ядра — `document_issue` +`STATEMENTS_VERSION_MISMATCH` (`info`); у задания без версии (`1.x`) запись не пишется. Один PATCH — один пересчёт и одна +запись аудита `RECOGNIZED_DATA_EDIT`. Акцепт SHALL сбрасываться правкой данных — суммы, кода или статуса строки, даты +колонки, поля шапки; подтверждение или снятие подтверждения (`confirmed`) и подтверждение даты отчёта или даты колонки +тем же значением акцепт MUST NOT сбрасывать (Р-25, S-47). + +#### Scenario: Подтверждение ячейки без правки +- **WHEN** аналитик подтверждает ячейку с `needs_review = true` без изменения суммы +- **THEN** `is_confirmed = true`, `confirmed_by/at`, `is_modified` не ставится, `needs_review = false`, аудит; акцепт + проверок, если был, сохраняется + +#### Scenario: Снятие подтверждения +- **WHEN** аналитик снимает подтверждение с ячейки (`confirmed: false`), которая не покрыта соотношением +- **THEN** `is_confirmed = false`, `needs_review = true` после пересчёта, аудит + +#### Scenario: Родитель уходит в расшифровки +- **WHEN** у строки 1150 две расшифровки (`detail`, `parent_row_index` = 1150), аналитик отмечает саму 1150 расшифровкой +- **THEN** после пересчёта родитель всех трёх строк `detail` — ближайшая строка `ok` выше 1150; строк над ними нет — `null` + (на экране — в начале раздела) + +### Requirement: PATCH формы — строки, колонки, подтверждение, наименование организации +`PATCH /documents/{id}/statement` SHALL принимать помимо `header {inn, report_date, units}` и `values` (сумма): +- `header.organization_name` — непустая строка до 1000 символов: `organization_name_modified = true`, + `organization_name_original_value` не меняется, пересчитывается `counterparty_check`, аудит `before/after`; +- `header.report_date`, равный текущему, у поля из `needs_review_fields` — подтверждение даты: поле уходит из + `needs_review_fields`, снимается `REPORT_DATE_UNCERTAIN`, аудит; иначе запрос без изменений — 200 без эффектов, как + прежде; +- `values[] {line_id, column_id, confirmed: true | false}` — подтвердить ячейку без изменения значения или снять + подтверждение; +- `columns[] {column_id, period_date}` — дата колонке (обязательная дата; `null` — 422 ошибка валидации; пересчитывает + `is_current` и период документа по правилу §9.5 п. 2); ручная дата ставит колонке `period_kind = absolute`, чтобы + следующая правка даты отчёта её не перезаписала; дата, равная текущей, у колонки с `COLUMN_DATE_UNCERTAIN` или + `COLUMN_WITHOUT_DATE` — подтверждение: предупреждение снимается, `period_kind = absolute`, аудит (иначе запрос без + изменений — 200 без эффектов); правка `header.report_date` (в том числе `null`) пересчитывает `period_date` колонок с + `period_kind = relative:*` общим кодом (`null` даёт `null`); +- `lines[] {line_id, line_code}` — назначить или сменить код; `lines[] {line_id, line_status: "detail"}` — отметить + расшифровкой; `lines[] {line_id, confirmed: true | false}` — подтвердить код или снять подтверждение. + +Переходы: `unreadable` / `missing` / `detail` → `ok` назначением кода; `unreadable` / `missing` / `ok` → `detail` +отметкой; `ok` → `ok` сменой кода; `ok` с неуверенным кодом — подтверждением. Обратного перехода `detail` → +`unreadable` / `missing` нет: ошибочная отметка исправляется назначением кода, исходное состояние — перезагрузкой файла. +`line_status` принимает только `"detail"`; `"ok"` — 422 `LINE_CODE_REQUIRED` (в `ok` строку переводит только +`line_code`). Назначенный код: `line_code_confidence = 100`, `line_code_modified = true`, `needs_review` строки = +`false`. Подтверждённый код (`confirmed: true`): `is_confirmed = true`, `needs_review` строки = `false`, +`line_code_confidence` остаётся как пришло, `line_code_modified` не ставится; снятие подтверждения (`confirmed: false`) +возвращает `needs_review` по прежнему правилу (Р-34). `line_code_original_value` не меняется; после любого изменения статусов или кодов +`parent_row_index` **всех** строк `detail` документа пересчитывается как ближайшая строка `ok` выше по `row_index` +(требование «Пересчёт после правки»). Код — 4–5 цифр (иначе 422 `INVALID_LINE_CODE`), может совпадать с +существующим; незнакомый словарю — `info`. Строка с тем же кодом, **созданная правкой** (`row_index IS NULL`), с суммами — +409 `LINE_CODE_TAKEN` (сначала очистить её суммы или выбрать другой код); без сумм — удаляется (её `id` убирается из +`document_issue.line_ids`), её место занимает назначенная строка. Строки источника с тем же кодом не трогаются. В одном +элементе больше одного действия или ни одного — 422 `INVALID_LINE_PATCH`; строка не этого документа — 422 +`UNKNOWN_LINE`; колонка не этого документа — 422 `UNKNOWN_COLUMN`. Ошибки элемента `lines[]` (`LINE_CODE_TAKEN`, +`LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`) SHALL нести в `detail` `line_id` и +`line_code` первого неверного элемента (`line_code` — код элемента или `null`). Правка суммы по `line_code` (для достроенной строки) +при наличии строки `ok` с этим кодом — 422 `UNKNOWN_CELL` (передайте `line_id`). Удаления строк источника нет. Строки, +созданные правкой: `row_index = null`, `source_ref = null`, `line_status = ok`, `line_code_original_value = null`. Аудит +`RECOGNIZED_DATA_EDIT` с `before/after` по коду, статусу, родителю, подтверждениям и наименованию организации. +Ограничения состояния — как прежде (`DOCUMENT_READONLY`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_STATEMENT`). + +#### Scenario: Назначение кода строке-мусору (AURORA-1462) +- **WHEN** аналитик назначает строке `unreadable` «ИЧ,250» (`row_index = 2`) код 1250 +- **THEN** строка `ok`, `line_code = 1250`, `line_code_confidence = 100`, `line_code_modified = true`, + `line_code_original_value` остаётся `null`, `row_index` остаётся 2 (`origin = source`); §7 и статус пересчитаны + +#### Scenario: Отметка расшифровкой +- **WHEN** аналитик отмечает строку `missing` с суммой как расшифровку +- **THEN** `line_status = detail`, строка выходит из сумм и не блокирует подтверждение + +#### Scenario: Код занят шаблонной строкой с суммами +- **WHEN** аналитик назначает строке `unreadable` код 1250, а строка 1250 создана правкой аналитика (`row_index = null`) и + имеет сумму +- **THEN** 409 `LINE_CODE_TAKEN`, ничего не изменено + +#### Scenario: Код занят пустой шаблонной строкой +- **WHEN** аналитик назначает строке `unreadable` код 1250, а строка 1250 создана правкой и сумм не имеет +- **THEN** шаблонная строка удалена, её `id` убран из `document_issue.line_ids`; строка источника стала `ok` с кодом 1250 + +#### Scenario: Правка наименования организации +- **WHEN** у формы `NO_ORGANIZATION_NO_INN`, аналитик вводит `header.organization_name` +- **THEN** наименование сохранено, `organization_name_modified = true`, исходное — в `organization_name_original_value`, + `counterparty_check` пересчитан, предупреждение снято, аудит + +#### Scenario: Подтверждение неуверенной даты отчёта +- **WHEN** `report_date` в `needs_review_fields`, аналитик отправляет PATCH с тем же `header.report_date` +- **THEN** `needs_review_fields` пуст, `REPORT_DATE_UNCERTAIN` снят, статус пересчитан, аудит + +#### Scenario: Ручная дата относительной колонки +- **WHEN** колонке `relative:prev_year_end` аналитик ставит `period_date` 31.12.2024, затем правит дату отчёта +- **THEN** после первой правки `period_kind = absolute`; правка даты отчёта её дату не меняет + +#### Scenario: Подтверждение неуверенной даты колонки +- **WHEN** у колонки `COLUMN_DATE_UNCERTAIN`, аналитик отправляет `columns: [{column_id, period_date}]` с той же датой +- **THEN** предупреждение снято, `period_kind = absolute`, статус пересчитан, аудит + +#### Scenario: Статус ok без кода +- **WHEN** приходит `lines: [{line_id, line_status: "ok"}]` +- **THEN** 422 `LINE_CODE_REQUIRED` + +### Requirement: Подтверждение формы — порядок 409 +`POST /documents/{id}/confirm` для формы ББ/ОФР SHALL проверять в порядке: `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` → +`DOCUMENT_NOT_CONFIRMABLE` → `PERIOD_REQUIRED` → `LINES_UNRESOLVED` (есть строки `unreadable` / `missing`, у которых хотя +бы в одной ячейке `amount` или `string_value` не `null`; `detail.lines[] {line_id, row_index, line_code_text}`; строки без +значений не мешают) → `DUPLICATE_FORM` (в файле уже подтверждена другая форма того же типа, `period_year` и +`period_quarter`; `detail.document_id`) → `CHECKS_NOT_PASSED` (записи `document_issue` вида `relation` без акцепта). +Между файлами повтор формы решает автозамена §9.5 п. 3. + +#### Scenario: Неразобранная строка с суммой +- **WHEN** у формы строка `missing` с суммой и одновременно жёсткое нарушение +- **THEN** 409 `LINES_UNRESOLVED` с этой строкой в `detail.lines` + +#### Scenario: Неразобранная строка только с текстом +- **WHEN** у формы строка `unreadable` без сумм, но с мусором `211(1 |` в ячейке значения (`string_value`) +- **THEN** 409 `LINES_UNRESOLVED` — потерянная сумма тоже требует разбора + +#### Scenario: Вторая форма того же периода +- **WHEN** в файле два ББ на 30.09.2025, первый подтверждён, аналитик подтверждает второй +- **THEN** 409 `DUPLICATE_FORM` со ссылкой на первый + +### Requirement: Словари ядра и достройка шаблонных строк +Копия словаря §7 ядра (`api/rules/statement_checks.yaml`) SHALL удаляться: проверки ядро считает копией пакета +`arm_statements`. `api/rules/statement_lines.yaml` SHALL оставаться только словарём имён (код → наименования по +редакциям): у кода может быть несколько имён (синонимы и законные повторы кода в бланке, Р-20), загрузчик повтор кода в +редакции MUST NOT запрещать; сверка наименования — с любым именем кода; шаблонная строка берёт первое имя. Порядок и +состав редакций — из копии перечня кодов пакета; `sync-statements --check` сверяет, что все коды словаря имён есть в +перечне. В PR перехода ядра на v2.0 словарь имён SHALL быть дополнен: в редакции `fsbu4` — вторые имена законных +повторов 1220 «НДС, начисленный по отгрузке», 1520 «Обязательство по аренде», 2410 «Налог при упрощенной системе +налогообложения» (Р-20) и коды печатной формы 1С 1105 «Гудвил», 1130 «Нематериальные поисковые активы», 1140 +«Материальные поисковые активы», 1530 «Доходы будущих периодов», 2460 «Прочее» (имена — по `fixtures/ocr` и шаблонам +банка); без этого законные повторы РБ, Счастливочной и aep_6 и любой неуверенный 1105 дают `NAME_MISMATCH`. Шаблонные строки достраиваются по словарю имён (одна на код); код, у которого есть хотя бы одна строка `ok` из +файла, шаблоном MUST NOT достраиваться; строки `unreadable` / `missing` не заменяются шаблонной строкой молча. + +#### Scenario: Код есть только у строки unreadable +- **WHEN** у формы нет строки `ok` с кодом 1250, но есть строка `unreadable` «ИЧ,250» +- **THEN** шаблонная строка 1250 достраивается пустой, строка `unreadable` показывается отдельно со статусом + +#### Scenario: Два имени у кода +- **WHEN** в редакции `fsbu4` словаря имён код 1520 записан дважды — «Кредиторская задолженность» и «Обязательство по + аренде» +- **THEN** словарь загружается; шаблонная строка 1520 одна, с именем «Кредиторская задолженность» + +#### Scenario: Гудвил с заголовком раздела в имени +- **WHEN** строка 1105 (уверенность 67) с наименованием «АКТИВ I. Внеоборотные активы Гудвил» +- **THEN** справочное «Гудвил» (≥ 5 символов) входит в него — совпало, `NAME_MISMATCH` нет + +### Requirement: Ответ `GET …/statement` по v2.0 +`StatementOut` SHALL отдавать: у документа `form_variant`; у шапки `needs_review_fields`, `source` (`header_source`; +`confidence` — строкой Decimal, как прочие уверенности API), `organization_name_modified` и `okei`; у колонок `period_kind`, `label_confidence`; у строк `id`, `origin` (`template` ⇔ +`row_index = null`), `row_index`, `source_ref`, `line_status`, `line_code`, `line_code_text`, `line_code_confidence`, +`line_name` (из файла), `reference_name` (справочное имя по коду — первое имя кода в словаре — или `null`), +`parent_row_index`, `parent_code`, `needs_review`, `is_confirmed`, `line_code_modified`; у значений `text` +(`source_text`), `needs_review`, `is_confirmed` и прежние поля; `issues[]` — записи `document_issue` с `line_ids`, +`column_id`, `column_index`, `field`, `codes`; `checks` — нарушения из `document_issue` с `codes` и `line_ids`; +`edition_lines[] {code, name}` — справочник строк для подсказки кода: коды словаря имён формы в редакции ответа по одному +(имя — первое), у формы без редакции — коды всех редакций по одному. `DocumentBrief` (`FileOut.documents[]`) и +`DocumentOut` SHALL отдавать `issue_counts {error, info}` — число записей `document_issue` документа по уровню. Порядок +`lines` — по возрастанию кода, как сейчас (`schemas/statements.py`), одинаковые коды — по `row_index`; строки +`unreadable` / `missing` — после ближайшей предыдущей по `row_index` строки, размещённой по коду, без такой — в начале; +`detail` — после родителя. Разделы и размещение итогов на экране («как на бумаге», Р-14) строит экран. + +#### Scenario: Документ, принятый по 1.x +- **WHEN** форма загружена до перехода сервиса (результат 1.2) +- **THEN** у строк `line_status = ok`, `row_index` по порядку, `source_ref = null`, `origin = source`, у значений `text = + string_value`, `issues` содержат нарушения §7, посчитанные ядром, и `MAPPING_WARNING`; экран работает + +### Requirement: Миграция DBML v5.19 +Одна миграция Alembic SHALL приводить схему к DBML v5.19 и **только** добавлять колонки, таблицу, enum'ы и индексы и +заполнять значения по умолчанию (Р-24); §7 по существующим формам миграция MUST NOT считать, импорта копии +`arm_statements` в ревизии нет. Перед выкладкой база очищается от форм ББ/ОФР (требование «Очистка форм перед +выкладкой»), но миграция SHALL проходить и на непустой базе (стенды разработчиков, база, очищенная не полностью): +`row_index` — `NULL` у строк с `line_code_original_value IS NULL` (созданных правкой), у остальных — по возрастанию +`line_code` внутри формы с 0; `line_status = ok`, `needs_review` строк — `false`, `needs_review` значений — **НЕ** +`values_ok` (`values_ok` — правлено, или `is_recognized` не `false` и `confidence` равна 100 или пуста), `source_text = +string_value`, `is_confirmed = false`, `period_kind` — `absolute` при `period_date`, иначе `none`, `form_variant = +null`, `header_source` — из `*_original_value` и `inn_confidence`, `organization_name_original_value = +organization_name`, `document.file_ordinal` — по `(created_at, id)` внутри файла с 0 (у всех документов), +`related_group.crm_id` — `null`; `document_issue` — пуст. **Корректность нарушений у форм, оставшихся с до-миграционных +времён, не гарантируется:** у них нет записей `document_issue`, поэтому до первой правки экран не показывает нарушений +§7, а `POST …/confirm` не отвечает `CHECKS_NOT_PASSED`; первый PATCH пересчитывает форму общим кодом. Это цена Р-24; на +контуре её снимает очистка. Сверка `check_dbml_vs_orm.py` — против `data-model-v5.19.dbml`. + +Откат (`downgrade`) SHALL быть гарантирован на пустой БД и на данных, которые схема v5.18 может представить без потери +первичных данных и ручного ввода. `downgrade` SHALL останавливаться с ошибкой, называющей причину и число записей, и +ничего не менять, если есть: две строки с одним кодом в форме, строка с `line_code = null`, две формы одного типа в файле, +непустой `related_group.crm_id`, `organization_name_modified = true` (исходное наименование хранится только в новой +колонке). Остальные новые колонки и таблица `document_issue` — производные и сигнальные данные v5.19 (`needs_review`, +`source_text`, `header_source`, `is_confirmed` с `confirmed_by/at`, `period_kind`, `label_confidence`, `form_variant`, +`needs_review_fields`, `file_ordinal`, `statements_version`, `document_issue`): `downgrade` удаляет их и пишет в журнал +миграции, сколько записей с непустыми значениями удалено (подтверждения аналитика остаются в `audit_event`). Это не +«молчаливое удаление»: потеря названа в журнале и в этом требовании. + +#### Scenario: Цикл миграций +- **WHEN** `alembic upgrade head` → `downgrade base` → `upgrade head` на чистой БД +- **THEN** все три шага проходят, сверка ORM с DBML v5.19 — без расхождений + +#### Scenario: Миграция на непустой базе +- **WHEN** на стенде есть форма ББ, загруженная до v5.19, с несошедшимся `1600 = 1700`, и выполнена миграция +- **THEN** миграция проходит: у строк `row_index` по коду, `line_status = ok`, у документа `file_ordinal`, записей + `document_issue` нет — нарушение до первой правки не показано (корректность не гарантируется); после любого PATCH форма + пересчитана, и запись `RELATION_FAILED` появляется + +#### Scenario: Строка правки до миграции +- **WHEN** до миграции у формы есть строка 1250, созданная правкой (`line_code_original_value = null`), с суммой +- **THEN** после миграции у неё `row_index = null`, `origin = template`; строки источника пронумерованы с 0 + +#### Scenario: Откат на данных v2.0 +- **WHEN** в БД есть форма с двумя строками 2410, выполняется `downgrade` ниже v5.19 +- **THEN** откат останавливается с ошибкой о повторе кода; данные не изменены + +#### Scenario: Откат после подтверждений +- **WHEN** в БД нет несовместимых данных, но у ячеек есть `is_confirmed = true` и записи `document_issue` +- **THEN** `downgrade` проходит, удаляет колонки и таблицу и пишет в журнал число удалённых подтверждений и записей + +### Requirement: Очистка форм перед выкладкой +Перед выкладкой ядра с миграцией v5.19 на контур (шаг 3 порядка выкладки) база SHALL очищаться от форм ББ/ОФР (Р-24): +формы пересоздаются повторной загрузкой файлов уже по новым правилам. Очистку выполняет владелец или платформенная +команда по его заявке, на контуре — после резервной копии БД и до `alembic upgrade` v5.19 (на стендах разработчиков — по +желанию). Инструмент — скрипт ядра `scripts/purge_statements.py` (пишется в PR шага 3; описание здесь, кода нет) с +режимами `--dry-run` (по умолчанию: только отчёт) и `--apply`, одной транзакцией: +1. **Отбор:** документы `BALANCE` / `INCOME` (у файлов любого статуса, включая `SUPERSEDED`). +2. **Отказ без изменений**, если хотя бы один отобранный документ упомянут в `ex_snapshot_item` или привязан + (`consolidation_document`) к версии консолидации в статусе «Верифицирована» или дальше — `VERIFIED` или `ARCHIVED` + верифицированной версии (есть `ex_snapshot` с `source_version_id` версии): такая версия read-only и заморожена в ЕХ. + Отчёт называет версии, решение — за владельцем. Версии в работе, на согласовании, отклонённые (`REJECTED`) и + отменённые (`CANCELLED`, в том числе архивированные) очистке не мешают: привязки к ним снимаются, как к версиям + «в работе» (п. 3, 4), статус версий не меняется (Р-35). +3. **Файлы только с формами** — у файла нет документов `OSV`: удаляется файл целиком, как `DELETE /files/{id}` (§9.5 п. 5), + но без запрета `FILE_HAS_CONFIRMED_DOCUMENTS`: привязки документов к неверифицированным версиям (п. 2) снимаются + (статус версий не меняется), удаляются документы всех типов файла (`BALANCE`, `INCOME`, `DISCLOSURE`, `OTHER`) с + `financial_statement_*` и комментариями документов, задания `recognition_job` файла и `stored_file`; объектам S3 + ставится `expires_at = now()`; ссылка `superseded_by_file_id` других файлов на удаляемый обнуляется, а заменённые им + файлы остаются `SUPERSEDED` (повторная загрузка заменит их снова; отчёт перечисляет такие файлы, если в них есть ОСВ). +4. **Смешанные файлы** — у файла есть и ОСВ, и формы: удаляются только документы `BALANCE` / `INCOME` с их + `financial_statement_*`, комментариями и привязками; файл, ОСВ, `DISCLOSURE` / `OTHER` и задания остаются. Повторно + загрузить такой файл нельзя (дедуп по `sha256`), поэтому отчёт `--dry-run` перечисляет смешанные файлы отдельно, и + владелец решает до `--apply`, удалять ли их целиком (ключ `--include-mixed`). +5. **Аудит:** на каждый удалённый файл — системное `FILE_DELETE` (`user_id = NULL`, описание «очистка форм перед v5.19», + карточка файла в `before_value`); на документ смешанного файла — то же событие по файлу с перечнем удалённых + документов. +6. **Не трогаются:** ОСВ (`osv_entry`), консолидации и их версии, корректировки, гудвилл, справочники, пользователи, + журнал аудита. Результаты КФО неверифицированных версий, посчитанные с удалёнными формами, пересчитываются, как после + удаления файла. + +#### Scenario: Отчёт перед очисткой +- **WHEN** владелец запускает скрипт без ключей +- **THEN** скрипт ничего не меняет и печатает число файлов только с формами, смешанных файлов, документов форм, + подтверждённых документов и привязок к неверифицированным версиям + +#### Scenario: Форма в верифицированной версии +- **WHEN** документ ББ привязан к версии `VERIFIED` +- **THEN** `--apply` отказывает с перечнем версий, база не изменена + +#### Scenario: Форма в версии на согласовании или отклонённой +- **WHEN** документ ББ привязан к версии `VERIFICATION_RF`, а ОФР — к версии `REJECTED`; верифицированных версий нет +- **THEN** `--apply` выполняется: привязки сняты, статусы версий не изменились + +#### Scenario: Очистка и повторная загрузка +- **WHEN** очистка выполнена, ядро v5.19 выложено, аналитик загружает тот же PDF с ББ и ОФР +- **THEN** дедуп не мешает (файла больше нет), формы создаются по мапперу 1.x или 2.0 — смотря что шлёт сервис + +### Requirement: Предупреждения файла и ОСВ в API, статус ОСВ при приёме 2.0 +`recognition_job.metrics.warnings` SHALL хранить предупреждения уровня файла структурами `{code, severity, message, +locator?}` (`fileWarning` контракта): у `2.0` — как пришли, у `1.x` — строки, обёрнутые в `LEGACY_WARNING` `info`. +`JobBrief.warnings` карточки файла (`FileOut.job`) SHALL отдавать список этих структур, а не строк; строки заданий, +сохранённых до выкладки ядра v5.19, при чтении конвертируются в `{code: "LEGACY_WARNING", severity: "info", message: +<строка>, locator: null}` (данные в БД не переписываются). `JobBrief.mapping_warnings` (предупреждения маппинга ядра) +остаётся списком строк. Это несовместимое изменение типа поля для фронта — фиксируется в гайде фронта. + +Предупреждения листа ОСВ результата `2.0` (`osvDocument.warnings`, коды `OSV_*`, `info`) SHALL храниться в +`document_issue` документа ОСВ (вид `warning`, источник `service`) и отдаваться в `DocumentOut.issues[]` (те же поля, +что `issues` формы) вместе с предупреждениями маппинга ядра этого листа; `DocumentOut.warnings` (строки маппинга файла) +сохраняется на окно перехода. `osvDocument.status` SHALL использоваться: при приёме `2.0` документ ОСВ `RECOGNIZED` ⇔ +`osvDocument.status = done` ∧ `checks` листа без нарушений ∧ в `metrics.warnings` файла нет предупреждений уровня +`error` ∧ `counterparty_check ≠ MISMATCH` ∧ нет предупреждений маппинга ядра по этому листу (повтор счёта в файле, строки +без родителя, нарастающая по данным Q1); статус конверта в условие не входит (как Р-19 для форм). При приёме `1.x` — как +прежде (§9.3). + +#### Scenario: Карточка файла задания до выкладки +- **WHEN** у задания, сохранённого до v5.19, `metrics.warnings = ["sheet=ОСВ 60 строки без родителя"]` +- **THEN** `JobBrief.warnings = [{code: "LEGACY_WARNING", severity: "info", message: "sheet=ОСВ 60 строки без родителя", + locator: null}]` + +#### Scenario: Потерянный json в карточке файла +- **WHEN** результат `2.0` scan-gateway несёт `metrics.warnings` с `JSON_FAILED` +- **THEN** `JobBrief.warnings` содержит `{code: "JSON_FAILED", severity: "error", message, locator}`; предупреждение не + теряется + +#### Scenario: ОСВ в файле с ОФР, требующим проверки +- **WHEN** книга из ОСВ (`status = done`, контрольные суммы сошлись) и ОФР (`needs_check`), конверт `needs_check`, + предупреждений файла `error` нет +- **THEN** ОСВ `RECOGNIZED` и автоподтверждается; её предупреждения `OSV_*` — в `DocumentOut.issues` + +### Requirement: `crm_id` ГСК/ГСЗ +`related_group.crm_id` (`varchar`, nullable, уникален) SHALL сохраняться в `POST …/related-groups` и +`PATCH /directories/related-groups/{id}` и отдаваться в `RelatedGroupOut` / `RelatedGroupDetail`; повтор — 409 +`CRM_ID_EXISTS`; изменение — аудит `GROUP_CHANGE` с `before/after` (arm-core#112). Импорт ЕЦП `crm_id` групп не +заполняет (OQ-91). + +#### Scenario: Занятый crm_id +- **WHEN** создаётся ГСЗ с `crm_id`, который уже есть у другой ГСК или ГСЗ +- **THEN** 409 `CRM_ID_EXISTS`, ничего не создано diff --git a/openspec/changes/unified-statement-normalization/specs/arm-web/spec.md b/openspec/changes/unified-statement-normalization/specs/arm-web/spec.md new file mode 100644 index 0000000..2ce2387 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/specs/arm-web/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: Раскладка строк формы со статусами +Экран проверки формы ББ/ОФР ([41]) SHALL сохранять раскладку «как на бумаге» (Р-14): разделы, внутри — строки `ok` по +возрастанию кода, несколько строк одного кода — в порядке файла (`row_index`), шаблонные строки на своих местах, +пятизначные подстроки под родителем. Строка `unreadable` / `missing` SHALL стоять под ближайшей предыдущей по +`row_index` строкой, размещённой по коду, а без такой — в начале первого раздела; она подсвечена целиком, в ячейке +кода серым — сырой текст `line_code_text` с подсказкой. Строки `detail` SHALL быть скрыты под переключателем +«Показать расшифровки (N)» и показываться под родителем (`parent_row_index`) с отступом. Экран показывает справочное +имя строки (`reference_name`), файловое (`line_name`) — рядом, если отличается. Переключателя «как в файле» нет. + +#### Scenario: Код-мусор на своём месте +- **WHEN** строка `unreadable` «ИЧ,250» в файле стоит после строки 1240 +- **THEN** на экране она стоит сразу под 1240, подсвечена, в ячейке кода серым «ИЧ,250» + +#### Scenario: Расшифровки скрыты +- **WHEN** у формы три строки `detail` +- **THEN** по умолчанию их не видно, переключатель «Показать расшифровки (3)»; включённый показывает их под родителями + +### Requirement: Подсветка и предупреждения у места +Экран SHALL подсвечивать ячейки только по `needs_review` (в подсказке — `text` и уверенность), строки — по +`needs_review` строки, поля шапки — по `needs_review_fields` (в подсказке у поля — `header.source.<поле>`: сырой текст и +уверенность). Предупреждения и нарушения (`issues`, `checks`) SHALL показываться у места: у строк (`line_ids`), ячеек +(`line_ids` + `column_id`), полей шапки (`field`) или у документа (в том числе запись с `column_index` без `column_id` — +столбец вне колонок формы); уровень `error` отличается от `info`. Подсветки по `confidence < 100` и `is_recognized = +false` напрямую нет. У значений строк `detail` `needs_review` всегда `false` (Р-23): при показанных расшифровках их +сомнительные ячейки не подсвечиваются, но подсказка с `text` и уверенностью есть. + +#### Scenario: Догадка подтверждена проверкой +- **WHEN** значение `is_recognized = false`, `needs_review = false` +- **THEN** ячейка не подсвечена; в подсказке сырой текст и уверенность + +### Requirement: Правка строк, колонок и подтверждение в режиме «Редактировать» +В режиме «Редактировать» экран SHALL давать: у `unreadable` / `missing` — поле ввода кода с подсказкой из справочника +(код → справочное имя) и пункт меню строки «Это расшифровка»; у `detail` — «Назначить код»; у строки `ok` — «Сделать +расшифровкой», смену кода и «Подтвердить код» у строки с `needs_review`; подтверждение ячейки без правки; ввод даты у +колонки без даты (`period_kind = none`) или с предупреждением о дате (в том числе подтверждение той же даты); снятие подтверждения ячейки и кода (`confirmed: +false`); правка наименования организации в шапке; подтверждение даты отчёта из `needs_review_fields` без изменения (тем +же значением). «Сохранить» отправляет один `PATCH …/statement` (`header`, `values`, `columns`, `lines`). Кнопка «Подтвердить данные» при строках `unreadable` / `missing` с суммами +SHALL быть отключена с подсказкой «Разберите строки без кода: N» и переходом к первой такой строке; ответ сервера 409 +`LINES_UNRESOLVED` показывается тем же текстом, 409 `DUPLICATE_FORM` — со ссылкой на подтверждённую форму, 409 +`LINE_CODE_TAKEN` — у поля кода. Кнопка «Подтвердить данные» учитывает строки `unreadable` / `missing` с суммой или +нераспознанным текстом в ячейках. + +#### Scenario: Назначить код +- **WHEN** аналитик вводит 1250 в ячейку кода строки `unreadable` и сохраняет +- **THEN** уходит `PATCH` с `lines: [{line_id, line_code: "1250"}]`; после ответа строка встаёт на место 1250 + +#### Scenario: Подтверждение заблокировано +- **WHEN** у формы две строки `missing` с суммами +- **THEN** «Подтвердить данные» отключена, подсказка «Разберите строки без кода: 2», клик по подсказке ведёт к первой + +### Requirement: Предупреждения файла и документа в карточке файла +Карточка файла (панель файлов) SHALL читать `job.warnings` как список структур `{code, severity, message, locator}` +(после выкладки ядра v5.19 это не строки; у старых заданий ядро отдаёт строки, обёрнутые в `LEGACY_WARNING`), показывать +`message` и `locator`, отличая `error` от `info`; предупреждения листа ОСВ и документа — из `DocumentOut.issues[]`. +`job.mapping_warnings` остаётся списком строк. + +#### Scenario: Потерянный ОФР в карточке +- **WHEN** у задания `job.warnings = [{code: "JSON_FAILED", severity: "error", message: "…", locator: "file:x_ofr.json"}]` +- **THEN** карточка показывает предупреждение уровня «ошибка» с текстом и местом; экран не падает на объекте вместо строки diff --git a/openspec/changes/unified-statement-normalization/specs/quality-checks/spec.md b/openspec/changes/unified-statement-normalization/specs/quality-checks/spec.md new file mode 100644 index 0000000..5a3564d --- /dev/null +++ b/openspec/changes/unified-statement-normalization/specs/quality-checks/spec.md @@ -0,0 +1,45 @@ +## MODIFIED Requirements + +### Requirement: `make check` — быстрые проверки без Docker +Каждый сервисный репозиторий SHALL предоставлять цель `make check`, выполняющую из `api/`: `ruff check .`, +`ruff format --check .`, `mypy src` (в сервисе с копией общего кода `arm_statements` — `mypy src arm_statements`, в +`arm-core` — ещё `scripts`), `pytest -m "not integration"` (включая тесты копии `arm_statements/tests/`), тест +идентичности вендоренного контракта с `../docs/contracts` (схема копии — с `<имя>.v<M>.schema.json` своего `MAJOR`, +если такой файл есть в docs, иначе с основным; примеры — с `examples/v<M>/`, если там есть одноимённый файл, иначе с +`examples/`; правило — спека `recognition-contract`, требование «Копия контракта в сервисе идентична источнику»). Цель SHALL завершаться ненулевым кодом при первой упавшей проверке и MUST NOT требовать +запущенного Docker, PostgreSQL или NATS. Свежесть копии `arm_statements` относительно репозитория `arm-statements` в +`make check` MUST NOT проверяться (соседний репозиторий в `make check` не читается). + +#### Scenario: Файл не отформатирован +- **WHEN** в репозитории есть файл, не отформатированный `ruff format` +- **THEN** `make check` завершается с кодом 1 на шаге `ruff format --check`, следующие шаги не выполняются + +#### Scenario: Чистый репозиторий +- **WHEN** все проверки проходят +- **THEN** `make check` завершается с кодом 0 за время, не превышающее нескольких минут, без обращения к сети + +#### Scenario: Тесты копии общего кода +- **WHEN** в `tabular-parser` лежит копия `api/arm_statements/` с тестами пакета +- **THEN** `make check` гоняет их вместе с тестами сервиса, `mypy` проверяет и копию + +### Requirement: `make check-full` — полная проверка против compose +Каждый сервисный репозиторий SHALL предоставлять цель `make check-full`: `make check` плюс `pytest -m integration` против +инфраструктуры `docs/dev/docker-compose.yml` (PostgreSQL 16, NATS, MinIO, для `scan-gateway` — эмулятор). Для `arm-core` +цель SHALL дополнительно выполнять «`alembic upgrade head` → `alembic downgrade base` → `alembic upgrade head`» на чистой +БД и `python scripts/check_dbml_vs_orm.py` против текущей версии модели (`../docs/model/data-model-v<текущая>.dbml`, путь — переменная `DBML` Makefile). В сервисе с копией общего +кода `arm_statements` цель SHALL выполнять `statements-check` — `../docs/scripts/sync-statements.sh --check` по этому +хосту (спека `statement-normalization`, требование «`sync-statements` — копии пакета в потребителях»): коды `0` и `10` +— шаг зелёный (`10` — с предупреждением «копия отстала»), `20` и `2` — красный. `make check-full` SHALL быть +выполнен перед созданием PR, его итог (и предупреждение `statements-check`, если было) вписывается в описание PR. + +#### Scenario: Расхождение ORM и DBML +- **WHEN** в ORM добавлена колонка, которой нет в DBML +- **THEN** шаг `check_dbml_vs_orm` падает с перечнем расхождений, `make check-full` завершается с кодом 1 + +#### Scenario: Миграция без отката +- **WHEN** `downgrade` ревизии не удаляет созданный ею объект +- **THEN** шаг «upgrade → downgrade → upgrade» падает + +#### Scenario: Прямая правка копии общего кода +- **WHEN** в `scan-gateway` поправлен файл `api/arm_statements/steps/…`, а в `arm-statements` такого изменения нет +- **THEN** `statements-check` завершается с кодом 20 и называет файл, `make check-full` красный diff --git a/openspec/changes/unified-statement-normalization/specs/recognition-contract/spec.md b/openspec/changes/unified-statement-normalization/specs/recognition-contract/spec.md new file mode 100644 index 0000000..9480d2e --- /dev/null +++ b/openspec/changes/unified-statement-normalization/specs/recognition-contract/spec.md @@ -0,0 +1,210 @@ +## MODIFIED Requirements + +### Requirement: Сообщение результата распознавания +Тело сообщения на subject ответа (настройка окружения `NATS_RESULT_SUBJECT`, по умолчанию `files.tabular_parsed`) +SHALL соответствовать `docs/contracts/recognition-result-message.schema.json` +v2.0: обязательные поля `schema_version` (`"2.0"`), `job_id`, `file_id`, `status` (`processing` | `done` | `needs_check` | +`error`), `service {name, version}`; при `status ∈ {done, needs_check}` обязательны `result_s3 {bucket, key}` и +`result_schema_version`; при `status = error` обязателен `error {code, message}`; опциональны `raw_s3_key` +(только `scan-gateway`) и `metrics`. `metrics.warnings[]` — структуры `warning` (как в файле результата), не строки. +Поле `group_schema` в конверт NOT включается — источник `recognition_job.group_schema` — файл результата. +Производитель v2.0 SHALL писать в конверт тот же MAJOR, что и в файл результата. + +#### Scenario: Успешный результат +- **WHEN** сервис завершил обработку файла +- **THEN** файл результата записан в S3 по ключу `results/{file_id}/{job_id}/result.json`, а затем опубликован конверт + со `status = done | needs_check`, `result_s3` указывает на этот файл, `result_schema_version = "2.0"`, заголовок + `Nats-Msg-Id` = `result:<job_id>:<attempt>` + +#### Scenario: Ошибка обработки +- **WHEN** сервис не смог обработать файл (нечитаем, таймаут внешней системы, тип не поддерживается) +- **THEN** публикуется конверт со `status = error` и `error.code` из перечня схемы (`UNSUPPORTED_FORMAT`, `UNREADABLE`, + `TIMEOUT`, `SCHEMA_MISMATCH`, `NOT_IMPLEMENTED`, `FILE_READ_ERROR`, `INVALID_FORMAT`, `INVALID_DOCUMENT`, + `RECOGNITION_FAILED`, `UNKNOWN_ERROR`); `result_s3` может отсутствовать + +#### Scenario: Предупреждения в конверте +- **WHEN** у файла есть предупреждение уровня файла (ошибочный json набора ИС) +- **THEN** `metrics.warnings` конверта содержит объект `{code: "JSON_FAILED", severity: "error", message, locator}` + +### Requirement: Файл результата +Файл результата SHALL соответствовать `docs/contracts/recognition-result.schema.json` v2.0: один файл (`stored_file`) +→ один `job_id` → массив `documents` из `osvDocument` и/или `statementDocument`; денежные значения — строки +`^-?\d+(\.\d{1,2})?$`; `confidence` — 0–100. Пустой массив `documents` допустим только при `status = error`. +Предупреждение уровня файла пишется там, где документа нет (потерянный json, таблица ИС, исключение разбора таблицы, +лист «прочее»). +`service.statements_version` — коммит копии общего кода `arm_statements`, которым посчитан результат (обязателен при +наличии `statementDocument`). `metrics.warnings[]` — предупреждения уровня файла (структура `warning` с `locator`). + +Каждый документ (и `osvDocument`, и `statementDocument`) SHALL иметь `status` (`done` | `needs_check`) и `warnings[]` — +структуры `{code, severity, message, line_refs?, column_index?, field?}` (`line_refs` — только `ref` строк из `lines[]`); +у `osvDocument` все предупреждения — `info`, `status = done`, когда контрольные суммы §1.4 листа сошлись, иначе +`needs_check`. Остальное в `osvDocument` — как в v1.2: у строки `rows[]` необязательное поле `extra` — объект «заголовок +колонки → строка или null» для колонок внутри таблицы, не сопоставленных с шестью суммами и ИНН (§1.2 «не терять»); у +шапки — `period_start`, `period_end`, `is_from_year_start`. + +`statementDocument` SHALL иметь `form_variant` (`full` | `simplified` | `null`); `header` — поля `organization_name`, +`inn`, `report_date`, `units`, `okud`, `okpo`, `okopf`, `okfs`, `okei`, `address` и для каждого `<поле>_text` и +`<поле>_confidence`, плюс `needs_review_fields[]`; `columns[]` — `column_index` (номер столбца в сетке кандидата: у OCR +— `column_index` ИС, у Excel — номер столбца листа с 0), `label`, `label_confidence`, +`period_date`, `period_kind` (`absolute` | `relative:current` | `relative:prev_year_end` | +`relative:prev_prev_year_end` | `relative:prev_period` | `none`); `lines[]` — `row_index`, `ref` (у Excel `<лист>!<A1>`, +имя листа в кавычках по правилу Excel), `line_status` (`ok` | +`unreadable` | `missing` | `detail`), `line_code` (строка 4–5 цифр или `null`; `null` ⇔ статус не `ok`), +`line_code_text`, `line_code_confidence`, `line_name` (строка или `null`), `line_name_confidence`, `parent_ref` (у +`detail`), `needs_review`, `values[]` — `column_index`, `amount`, `string_value`, `text`, `confidence`, +`is_recognized`, `needs_review`. `checks` SHALL присутствовать у `BALANCE` / `INCOME` со строками и отсутствовать у +документа без строк; `relations_failed` и `soft_relations_failed` — `{column_index, relation, codes[], expected, +actual}`; `duplicate_line_codes` удалён. `document_type` — `BALANCE` | `INCOME` | `DISCLOSURE` (`OTHER` в v2.0 не +порождается: лист «прочее» — предупреждение файла `SHEET_OTHER`, документа нет; значение остаётся только в схеме 1.x и в +`document_type` ядра для документов прежних результатов); `form_variant` обязателен (`null` у `DISCLOSURE`); у документа +без строк `columns` и `lines` — пустые массивы. `ref` уникален в документе. + +#### Scenario: Результат валидируется примерами +- **WHEN** CI сервиса запускает тесты контракта +- **THEN** все файлы `docs/contracts/examples/result-*.json` проходят схему файла результата v2.0, а все + `request-*.json` / `result-message-*.json` — схемы сообщений + +#### Scenario: Лишняя колонка сохранена +- **WHEN** в ОСВ между ИНН и суммами есть колонка «КПП» +- **THEN** у строк контрагентов `extra = {"КПП": "<значение>"}`, файл проходит схему v2.0 + +#### Scenario: Форма из Excel с проверками +- **WHEN** tabular-parser разобрал книгу с балансом на двух листах, ОФР со строкой без кода под 2110 и пояснениями + (`examples/result-statement-excel.json`) +- **THEN** файл проходит схему v2.0: `BALANCE` с `locator` из двух листов, `status`, `checks` и мягким нарушением; + `INCOME` со строкой `detail` (`line_code = null`, `parent_ref`); `DISCLOSURE` с пустыми `columns`/`lines` и без `checks` + +#### Scenario: Форма из OCR с сомнительными значениями +- **WHEN** scan-gateway разобрал ББ, где одна сумма собрана из грязного текста и покрыта соотношением, а одна строка + `unreadable` (`examples/result-statement-minimal.json`) +- **THEN** файл проходит схему v2.0; у покрытого значения `is_recognized = false`, `needs_review = false`, `text` — + сырой; у строки `unreadable` `line_code = null`, `line_code_text` — сырой текст; документ `needs_check` + +#### Scenario: Границы периода ОСВ +- **WHEN** в шапке ОСВ период «Октябрь 2024 г. – Сентябрь 2025 г.» +- **THEN** `period_start = "2024-10-01"`, `period_end = "2025-09-30"`, `is_from_year_start = false`; документ ОСВ с + `status` и `warnings`, файл проходит схему v2.0 + +#### Scenario: Документ OTHER в v2.0 +- **WHEN** в результате v2.0 `statementDocument` с `document_type = OTHER` или без `form_variant` +- **THEN** файл не проходит схему v2.0 + +#### Scenario: Строка без кода с кодом в поле +- **WHEN** у строки `line_status = unreadable` передан непустой `line_code` +- **THEN** файл не проходит схему v2.0 + +### Requirement: Версионирование контракта +Поле `schema_version` SHALL быть строкой `MAJOR.MINOR`. Добавление необязательных полей или значений enum — +`MINOR` (обратно совместимо); удаление/переименование полей, смена типов или семантики — `MAJOR`. +Сервис SHALL отвергать запрос с неизвестным `MAJOR` (`error.code = SCHEMA_MISMATCH`); ядро SHALL принимать результат +с известным `MAJOR` и любым известным или новым `MINOR`. Запрос `recognition-request` остаётся v1.0; файл результата и +конверт — v2.0 (`MAJOR` поднят: `line_code` стал nullable, `metrics.warnings` и `document.warnings` — структуры, удалён +`checks.duplicate_line_codes`, изменён смысл `is_recognized` у OCR). На окне перехода схемы прежнего `MAJOR` SHALL лежать +в `docs/contracts/` рядом с текущими как `recognition-result.v1.schema.json` и `recognition-result-message.v1.schema.json` +(байтовые копии 1.2 и 1.0), их примеры — в `docs/contracts/examples/v1/`; `scripts/validate_contracts.py` проверяет оба +набора. Они удаляются тем же изменением, что удаляет маппер `1.x` ядра. + +#### Scenario: Несовместимая версия запроса +- **WHEN** сервис, поддерживающий запрос `1.x`, получает запрос `schema_version = "2.0"` +- **THEN** публикуется результат `status = error`, `error.code = SCHEMA_MISMATCH` **без ack `processing`**: ответом на + запрос служит сам финальный конверт, ядро закрывает им задание (`published → error`), и повторной доставки запроса не + будет. Конверт публикуется только если из тела извлекаются корректные `job_id` и `file_id`; иначе — как для нечитаемого + запроса (ответа нет, ядро закроет задание по `NO_ACK` / `dlq`). `attempt` для `Nats-Msg-Id` берётся из тела запроса; при его + отсутствии реализации расходятся (`tabular-parser` берёт `attempt` из заголовка `Nats-Msg-Id` запроса или не отвечает, + `scan-gateway` подставляет 1) — см. OQ-71 + +#### Scenario: Ядро получает MAJOR, которого не знает +- **WHEN** ядро, знающее `1.x` и `2.x`, получает конверт или файл результата `schema_version = "3.0"` +- **THEN** результат не проходит проверку схемы, файл получает `ERROR` с причиной в `metrics.mapping_error`, повтор не + выполняется (как для любого результата не по схеме) + +### Requirement: Копия контракта в сервисе идентична источнику +Каждый сервис, читающий или пишущий контракт, SHALL хранить копию файлов схем в `api/contracts/` и проверять в `make check` +их байтовую идентичность файлам `docs/contracts/` (`../docs` или путь `ARM_KA_DOCS`; при недоступности — пропуск с предупреждением). +Файл копии SHALL сверяться с файлом docs по правилу, одинаковому у ядра и сервисов и верному до, во время и после окна +перехода между `MAJOR`: +- **схема** `<имя>.schema.json` копии: `M` — номер из её `$id` (`…/v<M>`); пара — `docs/contracts/<имя>.v<M>.schema.json`, + **если такой файл есть**, иначе основной `docs/contracts/<имя>.schema.json` (у `recognition-request` файла `.v1` нет + — его копия `…/v1` сверяется с основным файлом; до слияния v2.0 в docs файлов `.v1` нет вовсе — все копии сверяются с + основными); +- **пример** `examples/<имя>.json` копии: вид — по префиксу имени (`request-` — запрос, `result-message-` — конверт, + `result-` — файл результата), `M` — `MAJOR` копии схемы этого вида; пара — `docs/contracts/examples/v<M>/<имя>.json`, + если такой файл есть, иначе `docs/contracts/examples/<имя>.json`; +- **прочие файлы копии** (`<имя>.v<N>.schema.json`, `examples/v<N>/*.json` — их держит ядро на окне) — с одноимённым + файлом docs; нет его в docs — расхождение; +- **набор:** каждой схеме и каждому примеру верхнего уровня docs (`contracts/*.schema.json` без `.v<N>`, + `contracts/examples/*.json`) SHALL соответствовать файл копии; файлы docs `*.v<N>.schema.json` и `examples/v<N>/` + требуются в копии, только если копия держит этот `MAJOR` (ядро на окне); файлы другого `MAJOR` сервисом не + сверяются. + +Правило вводится в тест идентичности **всех трёх** потребителей (`arm-core`, `tabular-parser`, `scan-gateway`) до +слияния контракта v2.0 в docs (шаг 0 выкладки); иначе их `make check` упадёт на окне перехода. Ядро держит копии обоих +`MAJOR` с PR шага 3; при удалении `MAJOR` 1 (шаг 6) ядро сначала удаляет свои копии `.v1`, затем docs — файлы `.v1`. + +#### Scenario: Копия устарела +- **WHEN** в `docs/contracts/` изменена схема, а копия в сервисе — нет +- **THEN** тест идентичности в `make check` сервиса падает с указанием имени файла + +#### Scenario: Сервис ещё пишет 1.x после слияния v2.0 в docs +- **WHEN** в docs основной `recognition-result.schema.json` уже v2.0, а копия в `tabular-parser` — v1.2 (`$id` `…/v1`) +- **THEN** тест идентичности сверяет копию с `recognition-result.v1.schema.json` и проходит; посторонний PR в сервис на + окне перехода не краснеет + +#### Scenario: Сервис перешёл на 2.0 +- **WHEN** `scan-gateway` в PR шага 5 обновил копию до v2.0 (`$id` `…/v2`) и примеры до v2.0 +- **THEN** тест сверяет схему с основным файлом docs (файла `.v2` нет), примеры `result-*` — с `examples/*.json`; + `examples/v1/` и `*.v1.schema.json` docs не сверяются + +#### Scenario: Запрос v1.0 после слияния v2.0 +- **WHEN** в docs уже есть `recognition-result.v1.schema.json`, а `recognition-request.v1.schema.json` нет; копия + запроса в сервисе — v1.0 (`$id` `…/v1`), примеры `request-image.json`, `request-tabular.json` +- **THEN** копия запроса сверяется с основным `recognition-request.schema.json`, примеры запроса — с + `examples/request-*.json` (в `examples/v1/` их нет); тест проходит + +#### Scenario: Примеры прежнего MAJOR +- **WHEN** копия `tabular-parser` — v1.2, в ней `examples/result-statement-excel.json` версии 1.2, а в docs + `examples/result-statement-excel.json` уже 2.0, `examples/v1/result-statement-excel.json` — 1.2 +- **THEN** пример копии сверяется с `examples/v1/result-statement-excel.json` и проходит + +#### Scenario: Шаг 0 до слияния docs +- **WHEN** тест с новым правилом слит в `arm-core`, `tabular-parser` и `scan-gateway`, а docs ещё на v1.2 без файлов `.v1` +- **THEN** все копии сверяются с основными файлами и примерами верхнего уровня, тест проходит, как прежде + +#### Scenario: Ядро на окне перехода +- **WHEN** копия ядра после шага 3 держит основные схемы v2.0, `*.v1.schema.json` и `examples/v1/` +- **THEN** основные сверяются с основными docs, `.v1` и `examples/v1/` — с одноимёнными файлами docs; набор совпадает + +## ADDED Requirements + +### Requirement: Переход на контракт v2.0 — «сначала ядро» +Ядро SHALL до перехода любого сервиса принимать конверты (включая ack `processing`) и файлы результата и `1.x`, и +`2.0`, с двумя мапперами файла результата. При приёме `1.x` статус документа формы SHALL считаться по прежнему правилу +D-6, где «проверки §7 сошлись» — по расчёту ядра копией `arm_statements` (полный набор), записанному в `document_issue`, +а поля v2.0 заполняться по умолчанию: `line_status = ok`, `row_index` — по порядку строк в результате с 0, `ref = +null`, `line_code_original_value = line_code`, `text = string_value`, `needs_review` значения = **НЕ** `values_ok` +(`values_ok` — `is_recognized` не `false` и `confidence` 100 или пуста: чистое значение — `needs_review = false`), +`needs_review` строки — `false`, `period_kind` — `absolute` при `period_date`, иначе `none`, `form_variant` — по +структуре кодов (точка входа пакета для пересчёта, спека `arm-core`), `file_ordinal` — индекс в `documents[]`; строки `metrics.warnings` — в `recognition_job.metrics.warnings` +структурами `{code: "LEGACY_WARNING", severity: "info", message}`; предупреждения маппера по документу — +`document_issue` `MAPPING_WARNING` (`info`); `statements_version` задания — `null`. Сервисы SHALL переходить на `2.0` +только после выкладки ядра, принимающего `2.0`. Маппер `1.x` и схемы `*.v1.schema.json` удаляются после перехода обоих +сервисов отдельным изменением. + +#### Scenario: Сервис ещё на v1.2 +- **WHEN** после выкладки ядра tabular-parser присылает результат `1.2` с повтором кода и `checks.duplicate_line_codes` +- **THEN** ядро принимает его маппером `1.x`: из двух строк с одним кодом хранится первая (прежнее правило маппера + `1.x`), вторая не хранится, у документа `MAPPING_WARNING` о повторе; нарушения §7 — в `document_issue` от ядра, статус + документа — по D-6, файл не `ERROR` + +#### Scenario: Чистое значение 1.x +- **WHEN** в результате `1.2` значение с `confidence = 100` и `is_recognized = true` +- **THEN** `needs_review = false`; значение с `confidence = 60` — `needs_review = true` + +#### Scenario: Ack нового сервиса +- **WHEN** scan-gateway после перехода публикует ack `processing` со `schema_version = "2.0"` +- **THEN** ядро переводит задание в `processing`, как для ack `1.0` + +#### Scenario: Порядок нарушен +- **WHEN** сервис прислал результат `2.0` с `line_code = null` ядру, знающему только `1.x` +- **THEN** результат не проходит проверку схемы, файл получает `ERROR` (`metrics.mapping_error`) — поэтому порядок + «сначала ядро» обязателен diff --git a/openspec/changes/unified-statement-normalization/specs/scan-gateway/spec.md b/openspec/changes/unified-statement-normalization/specs/scan-gateway/spec.md new file mode 100644 index 0000000..9a65887 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/specs/scan-gateway/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: Адаптер json ИС шага 1 +scan-gateway SHALL оставить у себя обмен с ИС Сканирование (горячие папки, опрос, срок, склейку набора json в +`raw/{job_id}.json` до нормализации, очистку папки) и адаптер «таблица `table_structure[i]` json → `FormCandidate`»; +шаги 2–8 SHALL выполняться копией пакета `arm_statements`. Адаптер SHALL: создавать кандидата на каждую таблицу каждого +успешного json; копировать шапку json (`organization`, `inn`, `units`, `date`) в шапку каждой своей таблицы как `Cell` +(`text = form_name`, `confidence` ИС); передавать `label.form_name` таблицы как заголовок в `evidence` (тип — общим +правилом шага 1 по словарю заголовков, `statement_labels.yaml` уходит в словарь пакета); строить плотную сетку по +`row_index` и `column_index` ИС (пустые позиции — `null`), `Cell.ref = row:<row_index>`; `locator = +file:<имя json>;table:<i>`. `value` у OCR-ячеек всегда `null`. + +#### Scenario: Один json — ББ и ОФР +- **WHEN** json `aep_3.json` содержит две таблицы — «Бухгалтерский баланс» и «Отчет о финансовых результатах» — под + общей шапкой +- **THEN** два кандидата с `locator` `file:aep_3.json;table:0` и `…;table:1`, у каждого копия шапки json + +### Requirement: Ошибки набора json +Ошибочный json набора (`parse_error`, непустой `error.code`, корень не объект) при хотя бы одном успешном соседе SHALL +давать предупреждение файла `JSON_FAILED` (`error`, `locator = file:<имя json>`, в `message` — код и сообщение ИС); +все json ошибочны — конверт `error` с кодом первого. Исключение при разборе таблицы — предупреждение **файла** +`TABLE_PARSE_ERROR` (`error`, `locator = file:<имя json>;table:<i>`; документа у таблицы нет), остальные таблицы +разбираются; ни одной таблицы не разобрано — `error RECOGNITION_FAILED`. + +#### Scenario: Таблица упала при разборе +- **WHEN** разбор `table:1` json `aep_3.json` бросил исключение, `table:0` разобрана +- **THEN** документ `BALANCE` из `table:0`; в `metrics.warnings` — `TABLE_PARSE_ERROR` уровня `error` с `locator = + "file:aep_3.json;table:1"`; конверт `needs_check`; ядро не автоподтверждает документы файла + +#### Scenario: Потерянный ОФР (решение № 28 среза 3a) +- **WHEN** `<job_id>.json` успешен (ББ), а `<job_id>_ofr.json` вернул `INVALID_DOCUMENT` +- **THEN** документ ББ получает свой статус; в `metrics.warnings` — `JSON_FAILED` уровня `error`; конверт + `needs_check`; ядро не автоподтверждает документы файла + +### Requirement: Что уходит из шлюза +scan-gateway SHALL удалить `api/rules/statement_lines.yaml` и подстановку наименований по справочнику, срезание +уверенности кода до 99 по справочнику, выбор редакции для имён (`_choose_edition`), пропуск строк с нечисловым и пустым +кодом со счётчиками, правило «оставлена первая строка» при повторе кода, синтез шапки, правило «ячейки под колонкой +нет → `confidence = 0`, не распознано», привязку `is_recognized` к `confidence` и правило «любое предупреждение → +`needs_check`». Модули `normalizer/ocr/text|dates|amounts` переезжают в общие разборы пакета, `layout|rows|header| +document` — в шаги 2–6 пакета; `ocr_lookalikes.yaml` — в словарь пакета. + +#### Scenario: Строки без кода в aep_3 +- **WHEN** в `aep_3.json` строки «БАЛАНС» и «Выручка» имеют пустой код, а в колонке значения мусор `211(1 |` +- **THEN** строки попадают в результат со статусами (`detail` под строкой с кодом, `missing` в начале таблицы) и сырым + текстом; значение `211(1 |` — `amount = null`, `string_value = "211(1 |"`, `is_recognized = false` (скобка внутри + цифр, правило шага 4), а не 2111; счётчиков «строк без кода с суммами пропущено» нет + +#### Scenario: Таблица ИС только с текстом +- **WHEN** в json есть таблица без столбца кодов и без распознанного типа, в ячейках только текст подписей +- **THEN** документа нет, в `metrics.warnings` — `TABLE_SKIPPED` уровня `info` с `locator` таблицы; конверт из-за неё + не `needs_check` diff --git a/openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md b/openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md new file mode 100644 index 0000000..469fff1 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/specs/statement-normalization/spec.md @@ -0,0 +1,885 @@ +## ADDED Requirements + +### Requirement: Пакет общего кода `arm_statements` и его копии +Общий код нормализации ББ/ОФР SHALL жить в репозитории `arm-statements` пакетом `arm_statements/` с `pyproject`, +своими тестами и конфигом линтеров. Пакет SHALL содержать: типы шага 1 (`FormCandidate`, `Cell`; сериализуемые в +json dataclass-ы), шаги 2–8, общие разборы текста (двойники, даты, суммы, коды), словари — проверок §7 +`statement_checks.yaml` (полный и упрощённый наборы), периодов `statement_periods.yaml`, заголовков форм и ОКУД, +подписей колонок («Код», «Наименование», исключения), букв-двойников, единиц измерения `statement_units.yaml` (написания +и коды ОКЕИ), упорядоченные перечни кодов форм по редакциям (`66n`, `fsbu4`) и вариантам (`full`, `simplified`) без +наименований — байтовая копия `docs/contracts/statement-code-orders.yaml` как `rules/statement_code_orders.yaml`, +диапазоны кодов — и загрузчик словарей. Словаря +«код → наименование» в пакете MUST NOT быть (Р-3). Пакет SHALL владеть своими типами входа и выхода; конвертацию +выхода в модель контракта делает хост. Golden-тесты пакета — внутри пакета на синтетических и обезличенных данных; +тесты на реальных фикстурах остаются в сервисах и соседние репозитории не читают. + +В tabular-parser, scan-gateway и arm-core пакет SHALL лежать копией вместе с тестами и конфигом линтеров и +импортироваться по имени `arm_statements`; раскладку, проверку свежести и маркер версии задаёт требование +«`sync-statements` — копии пакета в потребителях». + +#### Scenario: Наименований в пакете нет +- **WHEN** ревьюер ищет в пакете соответствие «код → наименование» +- **THEN** его нет: в перечне кодов только коды, наименования строк — словарь ядра + +### Requirement: `sync-statements` — копии пакета в потребителях +Копия пакета в хосте (`arm-core`, `tabular-parser`, `scan-gateway`) SHALL лежать в `api/arm_statements/` (рядом с +`api/src/`, импорт `import arm_statements`) вместе с тестами пакета (`api/arm_statements/tests/`); конфиг линтеров пакета в копию не кладётся (применяется конфиг +хоста); `ruff` и `pytest` хоста SHALL охватывать копию (тесты пакета — в `testpaths`), `mypy` хоста — запускаться и по +ней (`mypy src arm_statements`, у `arm-core` — `mypy src scripts arm_statements`). Маркер версии — файл +`api/arm_statements/VERSION` с полным хэшем коммита репозитория `arm-statements`, из которого разложена копия; его пишет +только скрипт, сервис читает при старте и пишет в результат как `service.statements_version`. Прямые правки копии MUST +NOT делаться. + +Копию SHALL раскладывать и проверять скрипт `docs/scripts/sync-statements.sh` (по образцу `sync-web.sh`; цель `make -C +docs sync-statements`). Источник — локальный checkout `arm-statements` рядом с workspace или `--source <dir>`, ссылка — +`origin/main` или `--ref`; хост — `--host <имя>` или `--host-dir <dir>`. Без `--check` скрипт раскладывает копию коммита +`--ref` в новый worktree хоста на ветке фичи, пишет `VERSION` и готовит issue и PR (как `sync-web.sh`). С `--in-place` скрипт SHALL раскладывать +копию коммита `--ref` в рабочее дерево указанного хоста (существующий worktree) без issue, коммита и PR — для +подключения хоста и для PR фичи, которой нужна новая версия пакета; при прямой правке копии или недостающей зависимости — +отказ с кодом `20`, ничего не пишется. Копия +сравнивается с деревом `arm_statements/` источника без файла `VERSION` (его в источнике нет). `--check` SHALL +возвращать: `0` — копия равна дереву `arm_statements/` коммита из `VERSION`, это коммит `--ref`, и копия перечня кодов +в пакете равна `docs/contracts/statement-code-orders.yaml`; `10` — копия равна дереву коммита из `VERSION`, но в +`--ref` есть более новый (копия отстала), или копия перечня кодов отличается от файла docs (перечень в docs правился +раньше `arm-statements` или пересинхронизации хоста — предупреждение называет файл); `20` — копия отличается от дерева +коммита из `VERSION` (прямая правка), `VERSION` нет в истории источника, зависимости пакета (список `dependencies` +его `pyproject`) отсутствуют в зависимостях хоста или (только у `arm-core`) код словаря имён +`api/rules/statement_lines.yaml` отсутствует в перечне своей редакции **копии пакета**; `2` — нет checkout источника +или хоста, неизвестный аргумент. Файлы и коды расхождения SHALL печататься. Порядок правки перечня кодов: PR в docs → +PR в `arm-statements` (байтовая копия yaml) → `sync-statements` в хостах «сначала ядро»; до конца этой цепочки хосты +получают `10`, а не `20`. + +Код пакета SHALL проходить самый строгий конфиг `ruff` и `mypy` среди хостов (пакет проверяется им в своём +репозитории): конфиг хоста применяется к копии, а правка копии под хост запрещена. Тесты пакета SHALL не зависеть от +фикстур и `conftest` хоста: в `api/arm_statements/tests/` свой `conftest.py`, а `conftest` хоста MUST NOT содержать +`autouse`-фикстур без ограничения каталогом `tests/` хоста. Зависимости пакета — только те, что есть в зеркале банка +(сейчас PyYAML); хост держит их в своих зависимостях с версией зеркала. + +Цель `statements-check` хоста (`sync-statements.sh --check --host-dir .`, docs — `../docs` или `ARM_KA_DOCS`) SHALL +входить в `make check-full` (коды `0` и `10` — зелёная, `10` с предупреждением «копия отстала», вписывается в описание PR; +`20` и `2` — красная) и MUST NOT входить в `make check` и пайплайн платформы. + +#### Scenario: Прямая правка копии +- **WHEN** в `tabular-parser` изменён файл копии пакета, а источник в `arm-statements` — нет +- **THEN** `sync-statements --check` завершается с кодом 20 и называет файл; `make check-full` красный; `make check` + сервиса этого не проверяет + +#### Scenario: Копия отстала +- **WHEN** копия в `scan-gateway` равна коммиту `abc1234`, а в `arm-statements` `origin/main` уже `def5678` +- **THEN** `--check` завершается с кодом 10, `make check-full` зелёный с предупреждением «копия отстала» + +#### Scenario: Нет checkout источника +- **WHEN** рядом нет `arm-statements` и `--source` не задан +- **THEN** `--check` завершается с кодом 2 и подсказкой, `make check-full` красный + +#### Scenario: Перечень кодов в docs новее копии +- **WHEN** в docs в `statement-code-orders.yaml` добавлен код, а `arm-statements` и копии в хостах ещё не обновлены +- **THEN** `--check` в каждом хосте завершается с кодом 10 и называет файл перечня; `make check-full` зелёный с + предупреждением, посторонние PR в сервисы не краснеют + +#### Scenario: Код словаря ядра вне перечня +- **WHEN** в `arm-core/api/rules/statement_lines.yaml` редакции `fsbu4` есть код 1120, а в перечне `fsbu4` его нет +- **THEN** `--check` в `arm-core` завершается с кодом 20 и называет код и редакцию + +#### Scenario: Копия в PR фичи +- **WHEN** PR scan-gateway нужна версия пакета новее, чем в `main` хоста +- **THEN** `sync-statements.sh --in-place --host-dir .` в worktree фичи раскладывает копию и пишет `VERSION`, копия + коммитится отдельным коммитом этого PR; `--check` после раскладки — `0` + +#### Scenario: Версия общего кода в результате +- **WHEN** сервис собирает результат копией пакета, у которой в `VERSION` коммит `abc1234` репозитория `arm-statements` +- **THEN** в результате `service.statements_version = "abc1234"` + +### Requirement: Шаг 1 — кандидат в форму +Каждый адаптер шага 1 SHALL выдавать на файл список `FormCandidate`, и только с ним работают шаги 2–8. Поля +кандидата: `source` (`ocr` | `excel`); `locator` (`file:<имя json>;table:<i>` у OCR, `sheet:<A>[,<B>…]` у Excel); +`type_hint` (`BALANCE` | `INCOME` | `DISCLOSURE` | `null`); `evidence` (ОКУД и заголовок формы как `Cell`); `header` +(поля `organization_name`, `inn`, `report_date`, `units`, `okud`, `okpo`, `okopf`, `okfs`, `okei`, `address` — каждое +`Cell` или `null`); `rows` — плотная сетка строк × ячеек, все страницы и листы формы уже в одной сетке, пустая позиция — +`null`; `segment_breaks` — номера строк сетки, с которых начинается новый лист Excel (у OCR пусто); `warnings`. + +`Cell` SHALL быть одной для шапки и таблицы: `text` — как показывает источник; `value` — число или дата, если +источник их знает (у OCR всегда `null`; у Excel — значение по числовому формату ячейки, tp#42); `confidence` 0–100 +(у Excel всегда 100); `ref` — позиция в источнике (OCR — `row:<row_index ИС>`, Excel — `<лист>!<A1>` по правилу Excel: +имя листа в одинарных кавычках, если в нём есть что-то кроме букв, цифр и `_` или оно начинается с цифры, апостроф внутри +имени удваивается). + +`type_hint` SHALL вычисляться одним правилом у обоих адаптеров: ОКУД по словарю (0710001 → `BALANCE`, 0710002 → +`INCOME`, прочие 0710xxx → `DISCLOSURE`) → иначе заголовок по словарю заголовков (у OCR заголовок — `label` таблицы +ИС; «Пояснения», «Расшифровка» → `DISCLOSURE`) → иначе `null`. + +#### Scenario: Excel-ячейка с числовым форматом +- **WHEN** в Excel ячейка хранит 106311116.46 с форматом `#,##0,` +- **THEN** у `Cell` `text = "106 311"`, `value = 106311`, `confidence = 100`, `ref = "Баланс!D27"` (лист и адрес + ячейки) + +#### Scenario: Имя листа с пробелом +- **WHEN** код строки стоит в ячейке C14 листа «Бухгалтерский баланс. Лист 1» +- **THEN** `ref = "'Бухгалтерский баланс. Лист 1'!C14"` + +#### Scenario: OCR-ячейка +- **WHEN** в таблице ИС ячейка строки `row_index = 8` содержит `"[190 :"` с уверенностью 25 +- **THEN** у `Cell` `text = "[190 :"`, `value = null`, `confidence = 25`, `ref = "row:8"` + +#### Scenario: Тип по ОКУД против заголовка +- **WHEN** у блока формы ОКУД 0710002, а заголовок «Бухгалтерский баланс» +- **THEN** `type_hint = INCOME`; окончательный тип выбирается по кодам на шаге 6 + +### Requirement: Шаг 1 — шапка кандидата +У каждого кандидата SHALL быть своя шапка. Текст каждого поля разбирается общим кодом по `Cell.text`: ИНН — только +цифры, без замены двойников, длина 10 или 12, иначе `null`; дата отчёта — словарём периодов (тот же, что для подписей +колонок, шаг 2); единицы — словарём единиц `statement_units.yaml` по тексту, а если по тексту не найдены — по коду ОКЕИ +тем же словарём (384 → «тыс. руб.», 383 → «руб.», 385 → «млн руб.»); ОКЕИ — только цифры. Сырой текст и уверенность +каждого поля SHALL доезжать до контракта (`<поле>_text`, `<поле>_confidence`). + +#### Scenario: ИНН с мусором клеток +- **WHEN** поле ИНН шапки OCR — `"3|1 21 2|"` с цифрами ИНН между вертикальными чертами +- **THEN** ИНН собирается только из цифр, `|` единицей не считается; `inn_text` хранит исходный текст + +#### Scenario: Единицы только кодом ОКЕИ +- **WHEN** в шапке Excel-формы нет текста единиц, а в блоке «Коды» стоит ОКЕИ 384 +- **THEN** `units = "тыс. руб."`, `okei = "384"`, предупреждения `UNITS_NOT_FOUND` нет + +### Requirement: Очистка OCR-текста — первая стадия типовых разборов +Очистка OCR SHALL выполняться не отдельным шагом, а первой стадией разборов даты, подписи колонки, кода строки, +разлепления склеенной строки и суммы — для любой ячейки без `value` (у Excel — только текстовые ячейки). Буквы- +двойники SHALL заменяться в датах, подписях, кодах и суммах (Р-11): `О/о/O/o → 0`, `З/з → 3`, `б → 6`, `S/s → 5`, +`I/l/|/і → 1`, `Ч → 4` — только вплотную к цифре, в несколько проходов; слова не портятся. В суммах крайние `|` +(след границы таблицы) SHALL убираться, а не заменяться. ИНН двойники MUST NOT трогать. Сумма с заменой двойника +получает `is_recognized = false`; код с заменой считается неуверенным. Текст длиннее 200 символов разбором даты не +обрабатывается (`null`). + +#### Scenario: Двойник в сумме +- **WHEN** ячейка суммы OCR — `"1 2О5"` с уверенностью 100 +- **THEN** `amount = 1205`, `is_recognized = false`, `text = "1 2О5"` + +#### Scenario: Двойник в коде +- **WHEN** ячейка кода — `"I1О0"` с уверенностью 100 +- **THEN** код разбирается как `1100` и считается неуверенным (подтверждается структурой, шаг 8) + +#### Scenario: Граница таблицы в сумме +- **WHEN** ячейка суммы — `"| : 122477"` +- **THEN** крайняя `|` удаляется, а не становится единицей; сумма 122477, `is_recognized = false` + +### Requirement: Шаг 1 — многостраничная форма +Требование относится к адаптеру Excel: у OCR каждая таблица ИС — отдельный кандидат, склейки таблиц нет (повторная +шапка внутри таблицы ИС — сегмент шага 2). У Excel одна форма SHALL быть одной сеткой на выходе шага 1. Строки продолжения дописываются в конец сетки по маркеру +«Форма … с.2» или по позиции листа; граница листа записывается в `segment_breaks`. Тип из маркера продолжения SHALL +сверяться с `type_hint` формы: расхождение — отдельный кандидат и предупреждение `CONTINUATION_TYPE_CONFLICT` +(`error`). Лист без маркера и без шапки присоединяется, только если его коды в диапазоне открытой формы и, при трёх и +более кодах, число колонок сумм совпадает с формой; иначе — отдельный кандидат и предупреждение +`SHEET_NOT_JOINED` (`error`). + +#### Scenario: Лист-продолжение с одной строкой +- **WHEN** после листа ОФР идёт лист без шапки с единственной строкой 2400 +- **THEN** лист присоединяется к ОФР (код в диапазоне формы, кодов меньше трёх — число колонок не сверяется), граница + листа — в `segment_breaks` + +#### Scenario: Лист другой ширины с тремя кодами +- **WHEN** лист без маркера и шапки содержит три кода ББ и четыре колонки сумм при трёх у открытой формы +- **THEN** лист — отдельный кандидат, предупреждение `SHEET_NOT_JOINED` уровня `error` + +### Requirement: Диапазоны кодов и разбор кода +Словарь диапазонов кодов SHALL содержать отрезки: ББ 1100–1799, ОФР 2100–2999, отчёт об изменениях капитала +3100–3699, отчёт о движении денежных средств 4100–4499, пояснения 5000–5999, отчёт о целевом использовании +6100–6499. Кодовые диапазоны для поиска столбца — объединение отрезков. Разбор кода: убрать пробелы и переносы → +заменить двойники (`|` → 1 только если результат попадает в кодовый диапазон) → убрать краевую пунктуацию → 4–5 цифр +(допускается хвост `.0`), первые четыре — в кодовом диапазоне. Многострочная ячейка — код, только если каждая её часть +код. «Диапазон столбца» — отрезок, в который попадает большинство кодов найденного столбца. + +#### Scenario: Год и сумма — не код +- **WHEN** в ячейке «2024» или четырёхзначная сумма «1050» или «7450» +- **THEN** кодом это не считается (вне отрезков); «3450» — код (отрезок 3100–3699 отчёта об изменениях капитала) + +#### Scenario: Краевая пунктуация и черта +- **WHEN** ячейка кода — `"1220 ,"` или `"| 140"` +- **THEN** коды `1220` и `1140` + +### Requirement: Шаг 2 — столбец кодов +Столбец кодов SHALL находиться одним правилом: (а) столбец с подписью из словаря подписей кода («код», «коды», «код +строки», «код показателя» — после нормализации регистра и пунктуации) над первой строкой с кодом, если под подписью +доля кодов среди непустых ячеек ≥ 0,6 или кодов ≥ 3; при нескольких кандидатах — с наибольшим числом кодов; берётся +последняя такая подпись над первой строкой с кодом; (б) иначе — столбец с наибольшим числом ячеек-кодов при кодах ≥ 3 +и доле ≥ 0,6; иначе предупреждение `CODE_COLUMN_NOT_FOUND`. + +#### Scenario: Титульный блок 1С с подписью «Коды» +- **WHEN** под подписью «Коды» титульного блока выгрузки 1С стоят ОКУД, дата, ОКПО, ИНН, ОКОПФ 12267, ОКФС, ОКЕИ + (один код из девяти, доля 0,11), а коды строк формы — в другом столбце +- **THEN** столбец под «Коды» не выбирается; столбец кодов — по правилу (б) + +#### Scenario: Документ из двух строк +- **WHEN** форма содержит две строки с кодами под подписью «Код» +- **THEN** столбец кодов найден (два из двух, доля 1,0) + +### Requirement: Шаг 2 — сегменты, повторная шапка и границы листов +Раскладка SHALL состоять из сегментов. Новый сегмент открывает строка-шапка (в ней есть ячейка из словаря подписей +кода, или не менее двух её непустых ячеек совпадают после нормализации с ячейками шапки первого сегмента) или граница +листа из `segment_breaks`. У сегмента своя раскладка: по его шапке; без шапки — столбец кодов по правилу шага 2, а если +сегмент не первый и кодов в нём меньше трёх — ослабленный поиск: столбец с наибольшим числом кодов из диапазона +столбца первого сегмента, минимум один. Колонки документа (`columns[]`) SHALL задаваться первым сегментом; колонки +любого следующего сегмента новых `columns[]` не создают, а сопоставляются с колонками первого сегмента: +- сегмент со своей шапкой (повторная шапка OCR, лист-продолжение Excel со своей строкой заголовка) — по `period_date`, + если у каждой колонки сегмента дата есть и совпадает с датой ровно одной колонки первого сегмента; иначе по порядку; +- сегмент без шапки — колонки значений — столбцы правее кода, где в строках с кодами есть числа, сопоставление по + порядку. + +Значения сегмента несут `column_index` сопоставленной колонки первого сегмента. При сопоставлении по порядку число +колонок сегмента не совпало с первым — предупреждение `SEGMENT_COLUMNS_MISMATCH` (`error`): значения по позиции в +пределах меньшего числа колонок, +лишние столбцы сегмента в `columns[]` не попадают, их номера в сетке перечислены в `message`. Столбец кодов не найден ни +так, ни так — предупреждение `SEGMENT_WITHOUT_CODES` (`error`) сегмента; строки сегмента не теряются (роль `data`, +статусы по шагу 3). + +#### Scenario: Лист-продолжение НПЗ со своей шапкой +- **WHEN** у ББ НПЗ («Бухгалтерский баланс. Лист 4») своя строка заголовка, колонки периодов — в столбцах 8–10, у первого + листа — в столбцах 16, 21, 24, даты подписей те же (30.09.2025, 31.12.2024, 31.12.2023) +- **THEN** новых колонок нет: значения листа 4 получают `column_index` 16, 21, 24 по совпадению `period_date`; + предупреждения нет, §7 ББ считается по одной колонке на дату, документ остаётся `done` + +#### Scenario: Повторная шапка с другими подписями +- **WHEN** повторная шапка второй страницы скана содержит две колонки без разобранной даты, у первого сегмента — две + колонки с датами +- **THEN** сопоставление по порядку, значения несут номера колонок первого сегмента, предупреждения нет + +#### Scenario: Сегмент шире первого +- **WHEN** у сегмента со своей шапкой четыре колонки периодов, у первого — три +- **THEN** `SEGMENT_COLUMNS_MISMATCH` уровня `error`, значения первых трёх — по позиции, номер четвёртого столбца — в + `message` + +#### Scenario: Лист «_2» НПЗ со столбцом кодов в другой колонке +- **WHEN** второй лист формы без шапки содержит единственную строку 2400, и код стоит не в той колонке, что на первом + листе +- **THEN** у сегмента свой столбец кодов (ослабленный поиск), значения строки 2400 не сдвинуты + +#### Scenario: Повторная шапка на второй странице скана +- **WHEN** в таблице ИС посередине стоит повторная строка «Наименование показателя | Код | На 30.09.2025 | …» +- **THEN** с неё начинается новый сегмент со своей раскладкой; строка-шапка в данные не попадает + +### Requirement: Шаг 2 — строка заголовка, нумерация и роли строк +Строка заголовка SHALL быть последней над первой строкой с кодом, где в столбце кодов стоит подпись кода; иначе +последней над первой строкой с кодом с двумя и более подписями-периодами правее кода (или одной, не голым годом); +двухстрочный заголовок склеивается. Строка нумерации SHALL получать роль `numbering` и в данные не попадать; строка — +нумерация, если в ней не меньше трёх непустых ячеек, каждая после замены букв-двойников — целое число, и числа слева +направо возрастают на 1, начинаются с 1 или 2 и допускают не больше одного пропуска. Роль +`title` — только строки выше найденной строки заголовка; в сегменте без заголовка роли `title` нет. Заголовка нет ни у +одного сегмента документа → колонки значений первого сегмента — столбцы правее кода, где в строках с кодами числа, +`label = ""`, `period_date = null`, `period_kind = none`, одно предупреждение `HEADER_ROW_NOT_FOUND` (`error`). +Заголовок есть хотя бы у одного сегмента — остальные сегменты берут его колонки без предупреждения. + +#### Scenario: Нумерация в скане (AURORA-1517) +- **WHEN** под шапкой таблицы ИС строка «1 | 2 | 3 | 4 | 5» +- **THEN** роль `numbering`, строки данных из неё нет + +#### Scenario: Нумерация с двойником +- **WHEN** строка нумерации скана распознана как «1 | 2 | З | 4» +- **THEN** «З» читается как 3, роль `numbering`; строки `unreadable` с «суммами» 3 и 4 нет + +#### Scenario: Нумерация без первой ячейки +- **WHEN** ИС потеряла первую ячейку нумерации: «2 | 3 | 4 | 5» +- **THEN** роль `numbering` (начало с 2 допускается) + +#### Scenario: Нумерация с пропуском +- **WHEN** строка «1 | 2 | 4 | 5» (тройка не распознана) +- **THEN** роль `numbering` (один пропуск допускается); «1 | 3 | 5» — не нумерация + +#### Scenario: Нумерация в Excel со «2» в столбце кодов +- **WHEN** в форме Межениновской под шапкой строка «1 | 2 | 3 | 4 | 5», где «2» стоит в столбце кодов +- **THEN** роль `numbering`; «2» кодом не считается + +#### Scenario: Листы 2–5 ББ без шапки +- **WHEN** у первого листа ББ заголовок есть, у листов 2–5 — нет +- **THEN** листы 2–5 берут колонки первого, предупреждения о заголовке нет, документ может быть `done` + +### Requirement: Шаг 2 — подпись и дата колонки +Подпись колонки SHALL разбираться одним словарём периодов `statement_periods.yaml`, объединяющим словарь +tabular-parser и шаблоны `dates.py` scan-gateway; строки `test_dates.py::test_parses_real_forms` scan-gateway — +обязательные тесты словаря. Результат — `period_date` (конец периода колонки) и `period_kind`: +- абсолютная подпись («На 31 марта 2025 г.», «31.12.2024», «30/09/25», «30092025», «За 9 месяцев 2025 г.», «с + 01.01.2025 по 31.03.2025», «2024», хвост «0:00:00», клетки «2|0|2|4|») → `absolute`; +- относительная: «На отчётную дату», «За отчётный период» → `relative:current` (= эффективная дата); «На 31 декабря + предыдущего года» → `relative:prev_year_end` (31.12 года эффективной даты − 1); «На 31 декабря года, + предшествующего предыдущему» → `relative:prev_prev_year_end` (31.12 года − 2); «За аналогичный период предыдущего + года» → `relative:prev_period` (эффективная дата − 1 год, 29 февраля → 28 февраля); +- подписи нет → `none`. + +Эффективная дата документа SHALL быть датой отчёта из шапки, иначе самой поздней абсолютной датой колонок; нет ни +той, ни другой → у относительных колонок `period_date = null`, `period_kind` остаётся относительным. + +Подпись из словаря исключений («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») SHALL пропускаться с +предупреждением `COLUMN_SKIPPED` (`info`). Подпись, не разобранная ни как период, ни как исключение, при числах в +столбце в строках с кодами SHALL оставлять колонку с подписью как есть, `period_date = null`, `period_kind = none` и +предупреждением `COLUMN_WITHOUT_DATE` (`error`, `column_index`); без чисел — пропуск. Подпись OCR с уверенностью ниже +100, разобранная в дату, подтверждается, если дата входит в ожидаемые: эффективная дата, она же минус один и минус два +года, 31 декабря предыдущего и предшествующего ему года; иначе предупреждение `COLUMN_DATE_UNCERTAIN` (`error`, +`column_index`). Уверенность подписи — `label_confidence` (у Excel 100; подписи нет — `null`). + +Подтверждение SHALL опираться только на уверенные источники, чтобы неуверенное не подтверждало само себя: для +неуверенной подписи эффективная дата — дата отчёта с уверенностью 100, иначе самая поздняя абсолютная дата колонок с +уверенной подписью (`label_confidence = 100`); такой нет — подпись не подтверждена. Неуверенная дата отчёта (шаг 8, поля +шапки) подтверждается только самой поздней абсолютной датой колонок с уверенной подписью. Для периода документа и +относительных подписей эффективная дата считается, как выше, по всем источникам. + +#### Scenario: Бланк 66н без впечатанных дат +- **WHEN** подписи колонок ББ — «На отчетную дату», «На 31 декабря предыдущего года», «На 31 декабря года, + предшествующего предыдущему», дата отчёта шапки 30.09.2025 +- **THEN** `period_date` колонок 2025-09-30, 2024-12-31, 2023-12-31, `period_kind` — `relative:current`, + `relative:prev_year_end`, `relative:prev_prev_year_end`; предупреждений о дате колонок нет + +#### Scenario: Неуверенная подпись не подтверждает сама себя +- **WHEN** даты отчёта в шапке нет, единственная колонка с абсолютной датой — подпись OCR «На 30.09.2О25» с уверенностью + 60, остальные колонки без подписи +- **THEN** эффективная дата для подтверждения строится без этой подписи — уверенных источников нет, подпись не + подтверждена: `COLUMN_DATE_UNCERTAIN` уровня `error`; период документа (`period_known`) по-прежнему определяется этой + датой + +#### Scenario: Мусорная подпись над числами +- **WHEN** подпись колонки — `"| | г"` с уверенностью 17, а в колонке суммы +- **THEN** колонка остаётся, `period_date = null`, `period_kind = none`, предупреждение `COLUMN_WITHOUT_DATE` уровня + `error` с `column_index`, документ `needs_check` + +#### Scenario: Колонка «Изменение» +- **WHEN** правее периодов колонка «Изменение, %» +- **THEN** колонка пропускается, предупреждение `COLUMN_SKIPPED` уровня `info` + +### Requirement: Шаг 2 — номер колонки +`column_index` колонки значений (`columns[]`, `values[]`, предупреждения) SHALL быть номером столбца в сетке кандидата +шага 1: у OCR — `column_index` ИС как есть (не обязательно подряд), у Excel — номер столбца листа первого сегмента с 0. +Номер уникален в документе; колонки сегментов без шапки сопоставляются с колонками первого сегмента по порядку и несут их +номера. Предупреждение о столбце, которого нет среди `columns[]` (`COLUMN_SKIPPED`), SHALL нести номер этого столбца в +сетке. + +#### Scenario: Разреженные колонки ИС +- **WHEN** в таблице ИС код стоит в колонке 1, суммы — в колонках 2, 4, 5 (колонки 3 нет) +- **THEN** `columns[].column_index` — 2, 4, 5, у значений — те же номера + +#### Scenario: Пропущенная колонка Excel +- **WHEN** на листе Excel коды в столбце C, периоды в D и E, «Изменение, %» в F +- **THEN** `columns[].column_index` — 3 и 4, предупреждение `COLUMN_SKIPPED` с `column_index = 5` + +### Requirement: Шаг 2 — столбец наименования +Столбец наименования SHALL быть столбцом с подписью, начинающейся с «наименование» (после нормализации), в строке +заголовка; иначе — столбцом левее кода с наибольшим числом текстов, кроме столбца «Пояснения». Наименование строки — +ячейка этого столбца; пустая — ближайшая текстовая ячейка левее кода, кроме «Пояснения». + +#### Scenario: Наименование без подписи +- **WHEN** у формы нет подписи «Наименование показателя», а левее кода два столбца — «Пояснения» (короткие номера) и + тексты статей +- **THEN** наименование берётся из столбца текстов статей + +### Requirement: Шаг 3 — строки данных и их статусы +Шаг 3 SHALL выдавать `RawLine` на каждую строку сетки с ролью `data`, у которой есть содержимое: в ячейке кода есть +хотя бы одна цифра (разобранный код или нет), или хотя бы одна ячейка значений даёт сумму (`amount` не `null`), или +непуста с уверенностью ниже 100, или её текст — «цифра под видом буквы»: после замены двойников без условия «вплотную к +цифре» в нём есть цифра и нет других букв (`'I'`, `'| О'`). Строка только с текстом наименования — не строка данных; +текст из настоящих букв в колонке значений при уверенности 100 (подписи «Руководитель … Косарев», «(расшифровка +подписи)» под формой Excel) содержимым не считается — осознанное исключение из «ничего не выбрасывать молча». У строки — `row_index` (порядковый номер в документе с 0, +по порядку сетки после разлепления), `ref` (уникален в документе: OCR `row:<row_index ИС>`, разлепленные +`row:<i>#<k>` с k от 1; Excel — адрес ячейки столбца кодов строки `<лист>!<A1>` по правилу шага 1, без столбца кодов — первой непустой +ячейки строки), ячейка кода, `line_status`, ячейка наименования, ячейки значений по колонкам сегмента, `parent_ref`. +Статусы: +- `ok` — код разобран; +- `unreadable` — в ячейке кода есть хотя бы одна цифра, но код не разобран; ячейка кода без цифр (в том числе только из + букв-двойников вне соседства с цифрой: «изо :», «}», «:») считается пустой; +- `detail` — строка без кода с содержимым, над которой (в том же или в предыдущих сегментах документа) есть строка `ok`; + `parent_ref` — `ref` ближайшей строки `ok` выше (строки `unreadable` / `missing` между ними пропускаются); +- `missing` — строка без кода с содержимым, над которой во всём документе нет строки `ok`; строки роли `title` + сюда не попадают. + +Строка без кода и без содержимого — не строка данных. Счётчиков отброшенных строк MUST NOT быть. Повтор кода — не +статус и не предупреждение: все строки с кодом — `ok`, порядок как в файле. + +#### Scenario: Код-мусор (AURORA-1462) +- **WHEN** ячейка кода — `"ИЧ,250"`, в колонках суммы +- **THEN** строка `unreadable` с `line_code_text = "ИЧ,250"`, суммами и `ref`; строка не пропадает + +#### Scenario: Мусор в ячейке кода без значений +- **WHEN** в ББ aep_6 ячейка кода — `"44 30\nI |"`, ячейки значений пусты +- **THEN** строка `unreadable` без значений: документ `needs_check` (не все строки `ok` / `detail`), подтверждение она не + блокирует (`LINES_UNRESOLVED` — только строки со значениями) + +#### Scenario: Расшифровка «в том числе» +- **WHEN** под строкой 1150 стоит строка «в том числе: Оборудование к установке» без кода с суммой +- **THEN** строка `detail`, `parent_ref` = `ref` строки 1150 + +#### Scenario: Расшифровка под строкой-мусором +- **WHEN** порядок строк: 1150 (`ok`), «ИЧ,250» (`unreadable`), строка без кода с суммой +- **THEN** последняя — `detail` с `parent_ref` строки 1150 (ближайшая строка `ok`, `unreadable` пропускается) + +#### Scenario: Код из букв-двойников без цифр (aep_3) +- **WHEN** ячейка кода — `"изо :"`, в колонке значений `'3'` с уверенностью 0, выше есть строки `ok` +- **THEN** цифр в ячейке кода нет — она пустая, строка `detail`; если это потерянная строка 1130, её сумма не войдёт в + 1100, и соотношение `1100 = …` подсветит раздел (цена правила, S-61) + +#### Scenario: Цифра под видом буквы (aep_6) +- **WHEN** в строке без кода под строкой `ok` единственная непустая ячейка значений — `'I'` с уверенностью 100 +- **THEN** строка данных `detail` (раньше терялась молча); значение `amount = null`, `string_value = "I"`, + `is_recognized = false` + +#### Scenario: Потерянный код в начале таблицы +- **WHEN** первая строка данных «Выручка» без кода с суммами (код уехал в ячейку значения), выше строк с кодом нет +- **THEN** строка `missing` + +#### Scenario: Повтор кода (AURORA-1406) +- **WHEN** код 2410 стоит у двух строк с разными наименованиями +- **THEN** обе строки `ok`, порядок как в файле, предупреждения нет + +### Requirement: Шаг 3 — разлепление склеенных строк +До разбора кода SHALL выполняться разлепление: ячейка кода содержит N ≥ 2 частей через перевод строки, и каждая +непустая ячейка значений — ровно N частей; разлепление делается, если хотя бы одна часть кода разобралась как код +диапазона столбца. Получается N строк с `ref` `row:<i>#<k>`; нечитаемая часть кода — строка `unreadable`; наименование — +k-я из непустых частей, если их тоже N, иначе целиком у каждой; `line_code_text` и `text` значений — k-е части. Значения разлепленных строк получают +`is_recognized = false`; разлепление — предупреждение `ROW_SPLIT` (`info`). Число частей не совпало или ни одна часть +не код — одна строка, многострочное значение → `amount = null`, `string_value` = текст, `is_recognized = false`. + +#### Scenario: Склейка «1500\n1700» (OQ-156) +- **WHEN** ячейка кода — `"1500\n1700"`, суммы — `"917 775\n2 371 333"` +- **THEN** две строки `ok` (`row:i#1`, `row:i#2`) с суммами 917775 и 2371333, `is_recognized = false`, предупреждение + `ROW_SPLIT`; при сошедшихся соотношениях §7 значения не подсвечиваются (Р-17) + +#### Scenario: Часть не из диапазона +- **WHEN** в ББ ячейка кода — `"44 30\nI |"` +- **THEN** строка не разлепляется (4430 вне диапазона ББ) + +### Requirement: Шаг 4 — значение ячейки +Шаг 4 SHALL выдавать `StatementValue {column_index, amount, string_value, text, confidence, is_recognized}`: `text` — +всегда сырой текст источника; `string_value` — только когда `amount = null` и текст непуст и не прочерк. Правила: +- `value` — число: `amount` квантуется до 2 знаков half-up; больше 16 целых цифр → `amount = null`, `string_value` = + текст, `is_recognized = false`, предупреждение `VALUE_OUT_OF_RANGE` (`info`); +- `value` — дата или логическое → `amount = null`, `string_value` = текст, `is_recognized = false`; +- `value` нет → `parse_amount(text, source)` по стадиям: + 1. пусто или прочерк (`-`, `–`, `—`, `−` без цифр) → `amount = null`, `is_recognized = true`; многострочное значение + в строке с однострочным кодом → `amount = null`, `string_value` = текст, `is_recognized = false`; + 2. крайние `|` (след границы таблицы) удаляются до замены двойников, а не становятся единицей (`"|122477"` → 122477); + затем буквы-двойники вплотную к цифре → цифры (Р-11); и то и другое — исправление (`is_recognized = false`); + 3. **ядро числа** — участок от первого из символов «цифра, `(`, `-`, `−`, `+`» до последнего из «цифра, `)`, `}`, + `]`»; всё вне ядра — **краевой мусор** (`|`, `:`, `=`, `■`, `•`, кавычки, точки, `{`, `[` и любые другие символы): + выбрасывается, а если среди выброшенного есть что-то кроме пробелов — `is_recognized = false`; + 4. в ядре допустимы только цифры, пробелы (в том числе NBSP и тонкие), `,`, `.`, знак (`-`, `−` как минус, `+`) в + начале и одна пара скобок вокруг всего числа: `(` первым символом и `)`, `}` или `]` последним (`}` / `]` — + исправление); `{` и `[` открывающей скобкой не считаются. Иначе — буква в ядре (после стадии 2), скобка без пары + или не на краю ядра, знак не в начале, любой другой символ между цифрами (`:`, `/`, `=`, `|`) — `amount = null`, + `string_value` = текст, `is_recognized = false`; + 5. цифр нет вовсе → `amount = null`, `string_value` = текст, `is_recognized = false`; + 6. скобки → минус; разделители: у `ocr` запятая — тысячи при шаблоне `[+-]?[1-9]\d{0,2}(,\d{3})+`, точка — + тысячи при `[+-]?[1-9]\d*(\.\d{3})+`, иначе десятичный разделитель, три и более знаков после него → `amount = null`, + `string_value` = текст; у `excel` запятая и точка — десятичный разделитель; пробел допустим только как разделитель + тысяч (группы ровно по три цифры), иначе `amount` — цифры подряд («лучшая догадка», не длиннее 16 цифр, иначе + `null`) и `is_recognized = false`; +- `is_recognized = true` — текст разобран как число без выброшенных и исправленных символов (замена двойника, `}` или + `]` вместо скобки — исправление); `confidence` — от источника и в `is_recognized` не входит; +- позиции нет, текст пуст или прочерк → `amount = null`, `string_value = null`, `is_recognized = true`, `confidence` + ячейки (позиции нет — 100). + +`parse_amount` — единственное место шагов 2–8, где разбор зависит от `source`. Разбор сумм ОСВ этим правилом MUST NOT +затрагиваться. + +#### Scenario: Потерянная цифра (AURORA-1511) +- **WHEN** ячейка OCR — `"42831 1"` с уверенностью 100 +- **THEN** `amount = 428311`, `is_recognized = false`, `text = "42831 1"` + +#### Scenario: Скобка `}` (OQ-147) +- **WHEN** ячейка OCR — `"(7744536}"` +- **THEN** `amount = -7744536`, `is_recognized = false` + +#### Scenario: Скобка внутри цифр (aep_3) +- **WHEN** ячейка OCR — `"211(1 |"` +- **THEN** крайняя `|` — краевой мусор; в ядре `211(1` скобка без пары и не на краю → `amount = null`, `string_value = + "211(1 |"`, `is_recognized = false` + +#### Scenario: Буква в ядре числа (aep_6) +- **WHEN** ячейка OCR — `"(Ю4)"` +- **THEN** «Ю» — не двойник и стоит в ядре → `amount = null`, `string_value = "(Ю4)"`, `is_recognized = false` (не −4) + +#### Scenario: Краевой мусор с двух сторон +- **WHEN** ячейка OCR — `": : 2856983 = ■"` или `"| : 122477"` +- **THEN** `amount = 2856983` и `amount = 122477`, `is_recognized = false` (выброшен мусор вне ядра) + +#### Scenario: Фигурная скобка не открывает +- **WHEN** ячейка OCR — `"{38378)"` +- **THEN** `{` — краевой мусор, в ядре `38378)` закрывающая скобка без пары → `amount = null`, `string_value = "{38378)"`, + `is_recognized = false` + +#### Scenario: Запятая по источнику +- **WHEN** текст `"4,200"` +- **THEN** у `ocr` `amount = 4200`; у `excel` `amount = 4.20` + +#### Scenario: Длинное тире (OQ-148, закрыт решением владельца 18.10.2026) +- **WHEN** ячейка OCR — `"—"` с уверенностью 100 +- **THEN** `amount = null`, `string_value = null`, `is_recognized = true` + +### Requirement: Шаг 4 — единицы и дробные значения +При единицах «тыс. руб.» и доле значений с дробной частью ≥ 10 % SHALL выдаваться предупреждение документа +`FRACTIONAL_IN_THOUSANDS` (`info`); то же сочетание в книге xlsb без числовых форматов — `XLSB_SCALE_SUSPECT` +(`error`). Книга xlsb без числовых форматов сама по себе — `XLSB_NO_FORMATS` (`info`); формат ячейки не разобран — +`NUMBER_FORMAT_UNPARSED` (`info`, одно на документ с перечнем форматов). + +#### Scenario: xlsb с копейками при «тыс. руб.» +- **WHEN** книга xlsb без стилей, в шапке «тыс. руб.», 40 % значений дробные +- **THEN** предупреждение `XLSB_SCALE_SUSPECT` уровня `error`, документ `needs_check` + +### Requirement: Шаг 5 — строка формы +Шаг 5 SHALL выдавать `StatementLine {row_index, ref, line_status, line_code, line_code_text, line_code_confidence, +line_name, line_name_confidence, parent_ref, needs_review, values}`: `line_code` — разобранный код (4–5 цифр) или +`null` у `unreadable` / `missing` / `detail`; `line_code_text` — сырой текст ячейки кода; `line_code_confidence` — от +источника, у кода с заменой двойника — не выше 99; `line_name` — распознанный текст наименования как есть, с +уверенностью источника. Сервисы MUST NOT подставлять наименование из справочника и MUST NOT срезать уверенность кода +по справочнику (Р-3). Редакция (`66n` / `fsbu4`) в сервисах нужна только для перечня кодов (шаг 8): проверяются обе +редакции, эффективная дата (`fsbu4` при дате после 2024-12-31) разрешает только равенство. + +#### Scenario: Наименование из OCR (AURORA-1522) +- **WHEN** у строки 1230 OCR-наименование «Дебиторская задолжен-ность» +- **THEN** `line_name = "Дебиторская задолжен-ность"` с уверенностью ячейки; справочное имя показывает ядро + +### Requirement: Шаг 6 — тип формы, вариант и документ +Шаг 6 SHALL собирать `StatementDocument {document_type, form_variant, locator, header, columns, lines}`. Тип — +большинство кодов строк `ok` в отрезке ББ → `BALANCE`, ОФР → `INCOME`, прочих форм и пояснений → `DISCLOSURE`; +совпало с `type_hint` или `type_hint = null` — тип по кодам; противоречит — тип по кодам и предупреждение +`TYPE_CONFLICT` (`error`); кодов нет — `type_hint`. Поиск столбца кодов (шаг 2) SHALL выполняться всегда, в том числе +при `type_hint = DISCLOSURE`: если большинство кодов в отрезке ББ или ОФР, тип — по кодам и `TYPE_CONFLICT`. Без столбца +кодов: `type_hint` есть → документ без строк (`DISCLOSURE` — `done`, иначе `needs_check` с `CODE_COLUMN_NOT_FOUND` +уровня `error`); `type_hint` нет → у `ocr` таблица ИС с содержимым — предупреждение **файла** `TABLE_LOST` (`error`, +`locator` таблицы), таблица ИС без содержимого — предупреждение **файла** `TABLE_SKIPPED` (`info`, `locator` таблицы); +у `excel` — лист «прочее», предупреждение **файла** `SHEET_OTHER` (`info`, `locator = sheet:<имя>`); документа во всех +этих случаях нет. Содержимое таблицы ИС — хотя бы одна ячейка с цифрой или с «цифрой под видом буквы» (шаг 3); таблица +только из текста (подписи, пояснительный текст) содержимого не имеет. У `DISCLOSURE` (по `type_hint` без кодов форм ББ/ОФР или по кодам прочих форм) шаги 3–7 +не выполняются: `columns` и `lines` — пустые массивы, `checks` нет, документ `done`. + +`form_variant` SHALL определяться только по структуре кодов всех строк `ok` документа (независимо от уверенности +кода; у ядра — заново при каждом пересчёте после правки, точка входа ниже): ББ — есть 1600 и нет ни 1100, ни 1200 → `simplified`; +ОФР — есть 2400 или 2110 и нет ни 2100, ни 2200, ни 2300 → `simplified`; иначе `full`; у `DISCLOSURE` — `null`. ОКУД +признаком варианта не служит. Шапка документа — поля `header` кандидата: `text` → `<поле>_text`, разобранное → поле, +`confidence` → `<поле>_confidence`. + +#### Scenario: Упрощённый баланс +- **WHEN** в ББ есть 1150, 1210, 1600, 1310, 1700 и нет 1100 и 1200 +- **THEN** `form_variant = simplified`; шаг 7 и перечень кодов берут упрощённый набор + +#### Scenario: «Пояснения» с кодами баланса +- **WHEN** ИС подписала таблицу «Пояснения к бухгалтерскому балансу» (`type_hint = DISCLOSURE`), а в столбце кодов — + 1110…1700 +- **THEN** столбец кодов найден, `document_type = BALANCE`, предупреждение `TYPE_CONFLICT` уровня `error`; данные не + выброшены + +#### Scenario: Лист «прочее» +- **WHEN** в книге Excel лист «Примечания» без заголовка формы, ОКУД и кодов +- **THEN** документа нет; в `metrics.warnings` — `SHEET_OTHER` уровня `info` с `locator = "sheet:Примечания"` + +#### Scenario: Заголовок спорит с кодами +- **WHEN** `type_hint = BALANCE`, а коды строк — 2110, 2120, 2100, 2200 +- **THEN** `document_type = INCOME`, предупреждение `TYPE_CONFLICT` уровня `error` + +### Requirement: Шаг 7 — самопроверки §7 +Шаг 7 SHALL считаться у обоих источников одним словарём `statement_checks.yaml` и выдавать `StatementChecks +{relations_failed[], soft_relations_failed[]}`; каждое нарушение — `{column_index, relation, codes, expected, +actual}`, где `codes` — коды цели и членов соотношения. Правила: +- значение кода в колонке — сумма по всем строкам `ok` с этим кодом; строки `detail`, `unreadable`, `missing` в + соотношения не входят; нарушение относится ко всем строкам его кодов; +- соотношение **посчитано**, если непуста цель или хотя бы один член (есть строка `ok` с кодом и `amount` не `null`); + пусто всё — соотношение пропускается; пустая цель при непустых членах и непустая цель при пустых членах — нарушение; +- жёсткое соотношение не сошлось, если расхождение больше допуска ±1 в единицах формы **и** больше 0,001 % наибольшего + по модулю числа соотношения — тогда оно в `relations_failed`; расхождение в пределах округления и мягкие + соотношения — в `soft_relations_failed`; +- члены `abs_terms` (2120, 2210, 2220, 2330, 2350, 2410) сравниваются и как записаны, и по модулю; такое соотношение + подтверждает величину, но не знак (шаг 8); +- набор соотношений — по `form_variant` (полный или упрощённый); `form_variant = null` — полный. В упрощённом наборе + соотношения ББ жёсткие, а `2400 = 2110 − 2120 − 2330 + 2340 − 2350 − 2410` ОФР — мягкое, как `2400` полного набора + (знак 2410, OQ-12); +- родитель пятизначной подстроки — ближайшая четырёхзначная строка выше с теми же первыми тремя цифрами, иначе код из + первых четырёх цифр; «сумма подстрок = родитель» (мягкое) — только если хотя бы одна подстрока в колонке непуста. + +`checks` SHALL быть у каждого `BALANCE` / `INCOME` со строками и отсутствовать у документа без строк. +`duplicate_line_codes` MUST NOT выдаваться. + +#### Scenario: «БАЛАНС» без кода +- **WHEN** строка «БАЛАНС» ушла в `detail` (код потерян), в колонке есть 1100 и 1200 +- **THEN** соотношение `1600 = 1100 + 1200` посчитано и нарушено (пустая цель при непустых членах), в + `relations_failed` + +#### Scenario: Лист только с активом +- **WHEN** в ББ заполнены 1100, 1200, 1600, а 1300–1700 отсутствуют +- **THEN** `1600 = 1700` нарушено (непустая цель при пустых членах), документ `needs_check` + +#### Scenario: Повтор кода в соотношении +- **WHEN** две строки 2410 с суммами 100 и 649 +- **THEN** в соотношениях код 2410 равен 749 + +#### Scenario: Упрощённый ОФР с доходом по налогу +- **WHEN** у упрощённого ОФР 2410 записан со знаком дохода, и `2400 = 2110 − 2120 − 2330 + 2340 − 2350 − 2410` по модулю + не сходится +- **THEN** нарушение в `soft_relations_failed`, документ из-за него не `needs_check` + +#### Scenario: Округление итогов +- **WHEN** `1600 = 1700` расходится на 9,02 при итогах ~17 млрд +- **THEN** нарушение в `soft_relations_failed`, на статус и подсветку не влияет + +### Requirement: Шаг 8 — `needs_review` значения +Сервис SHALL вычислять `needs_review` у каждого значения строк `ok`, `unreadable` и `missing`. У значений строк +`detail` `needs_review = false` всегда (Р-23): у них нет кода, покрыть их соотношением нельзя, в проверки §7, агрегаты и +статус документа они не входят; сомнение остаётся видно в подсказке ячейки по `text` и `confidence`. Для остальных +значений: +- значение **нуждается в покрытии**, если `confidence < 100` или `is_recognized = false`; чистые значения в покрытии не + нуждаются; +- значение **покрыто**, если `amount` значения не `null` и в его колонке есть соотношение с участием его кода, которое + посчитано и сошлось: жёсткое — в пределах допуска или округления; мягкое — только в пределах допуска ±1, без + «округления» (Р-21; так покрываются 2400, 2410, 2411, 2412, 2460, 25xx и пятизначные подстроки — у них жёстких + соотношений нет); +- **знак**: у члена `abs_terms`, нуждающегося в покрытии, знак подтверждён, если форма записи знака совпадает с формой + строгого большинства остальных членов `abs_terms` этой колонки с непустым `amount`, `is_recognized = true` и без + правки аналитика (сама ячейка в голосование не входит); форма знака — по результату разбора `parse_amount`: «скобки» + (пара скобок, в том числе с `}` / `]`), «минус», «без знака»; голосующих нет или равенство голосов — знак не + подтверждён; +- `needs_review = true`, если значение нуждается в покрытии и не покрыто или его знак не подтверждён; а также у всех + ячеек жёсткого соотношения, не сошедшегося вне допуска и округления, независимо от уверенности; +- иначе `needs_review = false`. + +#### Scenario: Догадка подтверждена соотношением (Р-7) +- **WHEN** у 1230 `"42831 1"` → 428311, `is_recognized = false`, и `1200 = 1210 + 1220 + 1230 + …` в колонке посчитано + и сошлось +- **THEN** `needs_review = false`; `text` и уверенность остаются в значении + +#### Scenario: Знак расхода в фигурной скобке +- **WHEN** в колонке ОФР 2210 и 2220 записаны в скобках, а 2120 — `"(7744536}"`, и `2100 = 2110 − 2120` сошлось +- **THEN** у 2120 знак подтверждён, `needs_review = false` + +#### Scenario: Знак вне конвенции +- **WHEN** 2210 и 2220 в скобках, а сомнительная 2120 — `"38378"` с уверенностью 60 (форма «без знака»), и `2100 = 2110 − + 2120` сходится по модулю +- **THEN** `needs_review = true` у 2120 независимо от соотношения + +#### Scenario: Скобка, которая не открывает +- **WHEN** 2120 — `"{38378)"` +- **THEN** разбор даёт `amount = null` (шаг 4), значение не покрыто, `needs_review = true` + +#### Scenario: Расшифровки не держат документ (Р-23, РБ 1 кв. 2025) +- **WHEN** в ББ и ОФР РБ «Бухгалтерская отчетность 1 кв.25» все неуверенные значения строк с кодом покрыты сошедшимися + соотношениями, неуверенные коды подтверждены перечнем `fsbu4`, а неуверенные значения остались только у строк `detail` + (в ОФР — `'1 425 130'` с уверенностью 78, `'134 796'` с уверенностью 71, `'(15 567)'` с уверенностью 75) +- **THEN** у значений `detail` `needs_review = false`, они статус не держат; документ получает `done`, если выполнены + остальные условия функции статуса (шапка, период, предупреждения); подсказка у ячеек `detail` показывает `text` и + уверенность + +#### Scenario: Справочная строка без проверки +- **WHEN** значение 2900 с уверенностью 80 (строк 29xx нет ни в одном соотношении) +- **THEN** `needs_review = true` + +#### Scenario: Чистая прибыль подтверждена мягким соотношением (Р-21) +- **WHEN** значение 2400 с уверенностью 66, а `2400 = 2300 − 2410 + 2420` в колонке сошлось с расхождением 0 +- **THEN** у 2400 `needs_review = false` + +#### Scenario: Мягкое соотношение в пределах округления не подтверждает +- **WHEN** значение 2400 с уверенностью 66, а `2400 = 2300 − 2410 + 2420` расходится на 9 при итогах ~17 млрд (в пределах + 0,001 %, но больше ±1) +- **THEN** у 2400 `needs_review = true`; нарушение в `soft_relations_failed` + +### Requirement: Шаг 8 — подтверждение неуверенного кода структурой +Код строки SHALL считаться неуверенным, если `line_code_confidence < 100` или при разборе заменён двойник; только к +неуверенным кодам применяется структурная проверка (Р-12). Данные — упорядоченные перечни кодов по редакциям (`66n`, +`fsbu4`) и вариантам (`full`, `simplified`), источник — `docs/contracts/statement-code-orders.yaml`; законные повторы +кода (Р-20) — часть перечня (`repeats`: у `fsbu4` 1220, 1520, 2410 печатной формы 1С; у `66n` — раздел III НКО). +Четырёхзначные коды строк `ok` документа образуют последовательность; коды, которых нет в перечне редакции, в +последовательность этой редакции не входят. Для **обеих** редакций берётся лучшая подпоследовательность, согласованная +с порядком перечня варианта формы (итоги после слагаемых; код с повтором может войти столько раз, сколько он стоит в +перечне); «лучшая» — лексикографически: сначала наибольшее число уверенных кодов (уверенные коды — якоря: включаются +все, какие можно включить вместе), затем наибольшая длина. Якоря, противоречащие друг другу по порядку бланка, все +вместе войти не могут — входит наибольшее согласованное их множество; неуверенный код проверяется против выбранного. +Редакция — с лучшей подпоследовательностью (то же сравнение), при равенстве — по эффективной дате (`fsbu4` после +2024-12-31). Неуверенный код подтверждён, только если он входит во **все** лучшие подпоследовательности выбранной +редакции. Вариант формы для перечня — `form_variant` документа (шаг 6, по всем строкам `ok`). Пятизначный код подтверждён, если +подтверждён его родитель и подстрока стоит после него. Повтор кода, которого нет в перечне редакции (или копий больше, +чем в перечне), у которого хотя бы одна копия неуверенна, — `needs_review = true` у неуверенной копии (Р-16); повтор из +перечня подтверждается структурой, как любой код. Неподтверждённый неуверенный код — `StatementLine.needs_review = +true`. У Excel и чистых OCR-кодов `needs_review` строки по структуре MUST NOT ставиться. + +#### Scenario: Неуверенный код на своём месте +- **WHEN** код 1230 с уверенностью 50 стоит между 1220 и 1240 (уверенными) +- **THEN** код подтверждён, `needs_review = false` + +#### Scenario: Переставленные неуверенные соседи +- **WHEN** порядок кодов 1210, 1230 (уверенность 50), 1220 (уверенность 50), 1240 +- **THEN** у обеих неуверенных строк `needs_review = true`, документ `needs_check` + +#### Scenario: Якоря противоречат друг другу +- **WHEN** порядок кодов 1210 (уверенный), 1240 (уверенный), 1230 (уверенный), 1250 (уверенность 50) +- **THEN** 1240 и 1230 вместе войти не могут; лучшие подпоследовательности — 1210, 1240, 1250 и 1210, 1230, 1250; код 1250 + входит в обе и подтверждён + +#### Scenario: Законный повтор 1220 (Р-20, AURORA-1431) +- **WHEN** в ББ печатной формы 1С по ФСБУ 4/2023 стоят подряд 1215, 1220 «НДС по приобретенным ценностям» (уверенность + 100), 1220 «НДС, начисленный по отгрузке» (уверенность 50), 1230 +- **THEN** обе строки 1220 подтверждены структурой (повтор есть в перечне `fsbu4`), `needs_review = false` + +#### Scenario: Повтор вне перечня +- **WHEN** в ББ код 1230 стоит дважды подряд, у второй копии уверенность 50 +- **THEN** у второй копии `needs_review = true`, документ `needs_check` + +#### Scenario: Бланк 66н в 2025 году +- **WHEN** скан ББ за 3 кв. 2025 на бланке 66н с неуверенным кодом 1120 между 1110 и 1130 +- **THEN** проверяются обе редакции, подпоследовательность `66n` длиннее, код 1120 подтверждён + +#### Scenario: Нестандартный код Excel +- **WHEN** в Excel-форме код 1231 или пятизначная подстрока 11101 вне перечня +- **THEN** `needs_review` строки не ставится; проверка «код не из редакции» — `info` ядра + +### Requirement: Шаг 8 — поля шапки +Дата отчёта с уверенностью ниже 100 SHALL подтверждаться совпадением с самой поздней абсолютной датой колонок с +уверенной подписью (`label_confidence = 100`; неуверенная подпись дату отчёта не подтверждает, требование «Шаг 2 — +подпись и дата колонки»); иначе +поле `report_date` попадает в `needs_review_fields` и выдаётся `REPORT_DATE_UNCERTAIN` (`error`, `field = +report_date`). У бланка только с относительными подписями колонок абсолютных дат нет, и неуверенная дата отчёта +структурой не подтверждается никогда: документ `needs_check`, аналитик подтверждает или исправляет дату в ядре (PATCH +шапки). `needs_review_fields` — поля, которые держат документ в `needs_check`; ИНН и организация с уверенностью ниже 100 +— предупреждения `INN_UNCERTAIN` / `ORGANIZATION_UNCERTAIN` (`info`, с `field`), в `needs_review_fields` не попадают +(подсветка у поля — по предупреждению); единицы и прочие поля на статус не влияют. + +#### Scenario: Неуверенная, но верная дата +- **WHEN** дата отчёта 30.09.2025 с уверенностью 60, самая поздняя дата колонок с уверенной подписью 30.09.2025 +- **THEN** `needs_review_fields` пуст, предупреждения о дате нет + +#### Scenario: Неуверенные дата и подпись не подтверждают друг друга +- **WHEN** дата отчёта 30.09.2025 с уверенностью 60, единственная абсолютная подпись колонки «На 30.09.2025» — с + уверенностью 60 +- **THEN** `needs_review_fields = ["report_date"]`, `REPORT_DATE_UNCERTAIN` и `COLUMN_DATE_UNCERTAIN` уровня `error`; + снимаются подтверждением даты отчёта и даты колонки аналитиком + +#### Scenario: Неуверенный год +- **WHEN** дата отчёта 30.09.2023 с уверенностью 60, колонки на 30.09.2025 и 31.12.2024 +- **THEN** `needs_review_fields = ["report_date"]`, `REPORT_DATE_UNCERTAIN` уровня `error`, документ `needs_check` + +#### Scenario: Бланк 66н с относительными подписями +- **WHEN** дата отчёта 30.09.2025 с уверенностью 60, подписи колонок — «На отчетную дату», «На 31 декабря предыдущего + года» +- **THEN** `needs_review_fields = ["report_date"]`, `REPORT_DATE_UNCERTAIN` уровня `error`; снимается подтверждением даты + аналитиком + +### Requirement: Шаг 8 — предупреждения +Предупреждение SHALL быть структурой `{code, severity, message, line_refs?, column_index?, field?}`: `severity` — +`error` | `info`; `line_refs` — список `ref` строк только из `lines[]` (о строках ролей `title` / `header` / +`numbering` — в `message`); `column_index` — номер столбца сетки (требование «Шаг 2 — номер колонки»); `field` — поле +шапки. Место в файле — `locator` документа, а не текст. Предупреждения уровня файла (`metrics.warnings`) дополнительно +имеют `locator?` (json набора ИС, таблица ИС или лист) и SHALL выдаваться там, где документа нет; предупреждение файла +уровня `error` делает конверт `needs_check` и блокирует `RECOGNIZED` всех документов файла в ядре. Уровни SHALL быть +такими: + +| Код | Уровень | Когда | +|---|---|---| +| `JSON_FAILED` | error (файл) | json набора ИС ошибочен при успешном соседе | +| `TABLE_PARSE_ERROR` | error (файл) | исключение при разборе таблицы ИС (`BACKEND_PARSING_ERROR`); документа нет | +| `TABLE_LOST` | error (файл) | таблица ИС с содержимым (ячейка с цифрой или «цифрой под видом буквы») без колонки кодов и без `type_hint` | +| `TABLE_SKIPPED` | info (файл) | таблица ИС без содержимого (только текст), без колонки кодов и без `type_hint`; документа нет | +| `CODE_COLUMN_NOT_FOUND` | error | у формы с `type_hint` `BALANCE`/`INCOME` нет столбца кодов | +| `SEGMENT_WITHOUT_CODES` | error | сегмент с содержимым без столбца кодов (в том числе лист-продолжение) | +| `SHEET_NOT_JOINED` | error | лист с ≥ 3 кодами и другим числом колонок не присоединился | +| `CONTINUATION_TYPE_CONFLICT` | error | тип маркера продолжения противоречит форме | +| `SEGMENT_COLUMNS_MISMATCH` | error | колонки сегмента при сопоставлении по порядку не совпали по числу с первым | +| `COLUMN_WITHOUT_DATE` | error | числовая колонка без разобранной подписи | +| `COLUMN_DATE_UNCERTAIN` | error | неуверенная подпись не попала в ожидаемые даты | +| `HEADER_ROW_NOT_FOUND` | error | заголовка нет ни у одного сегмента | +| `TYPE_CONFLICT` | error | тип по кодам противоречит `type_hint` | +| `NO_ORGANIZATION_NO_INN` | error | в шапке нет ни организации, ни ИНН | +| `PERIOD_UNKNOWN` | error | нет ни даты отчёта, ни абсолютных дат колонок | +| `REPORT_DATE_UNCERTAIN` | error | неуверенная дата отчёта не совпала с датами колонок | +| `XLSB_SCALE_SUSPECT` | error | xlsb без форматов при «тыс. руб.» и ≥ 10 % дробных | +| `INN_NOT_FOUND`, `INN_UNCERTAIN` | info | ИНН не найден или неуверен при найденной организации | +| `ORGANIZATION_NOT_FOUND`, `ORGANIZATION_UNCERTAIN` | info | организация не найдена или неуверенна при найденном ИНН | +| `REPORT_DATE_UNPARSED` | info | дата отчёта не разобрана при наличии дат колонок | +| `UNITS_NOT_FOUND` | info | единицы не найдены | +| `FILE_TYPE_MISMATCH` | info (файл) | формат файла не совпал с `file_type` запроса | +| `XLS_NO_CODEPAGE` | info (файл) | книга xls без кодовой страницы — текст мог прочитаться неверно | +| `COLUMN_SKIPPED` | info | подпись из словаря исключений пропущена | +| `FRACTIONAL_IN_THOUSANDS` | info | дробные при «тыс. руб.» (кроме xlsb без форматов) | +| `XLSB_NO_FORMATS`, `NUMBER_FORMAT_UNPARSED` | info | числовые форматы недоступны или не разобраны | +| `VALUE_OUT_OF_RANGE` | info | число Excel длиннее 16 целых цифр | +| `SHEET_OTHER` | info (файл) | лист Excel «прочее» без `type_hint` и кодов; документа нет | +| `ROW_SPLIT` | info | разлеплена склеенная строка | + +Предупреждения пути ОСВ — уровня `info`, код с префиксом `OSV_`. Новый код предупреждения вводится словарём пакета и +таблицей §11 `05-domain-rules.md`; `code` в контракте — строка `^[A-Z][A-Z0-9_]*$`, не перечисление. + +#### Scenario: Исключение при разборе таблицы ИС +- **WHEN** разбор второй таблицы json упал с исключением, первая разобрана +- **THEN** документ первой таблицы со своим статусом; в `metrics.warnings` — `TABLE_PARSE_ERROR` уровня `error` с + `locator = "file:<имя json>;table:1"`; конверт `needs_check` + +#### Scenario: НПЗ ОФР без ИНН +- **WHEN** в шапке ОФР есть организация и нет ИНН +- **THEN** `INN_NOT_FOUND` уровня `info`, документ может быть `done` + +#### Scenario: Привязка к ячейке +- **WHEN** не сошлось `1200 = 1210 + … + 1260` в колонке 1 +- **THEN** нарушение в `checks.relations_failed` с `column_index = 1` и `codes`, `needs_review = true` у ячеек этих + кодов в колонке 1 + +### Requirement: Шаг 8 — статус документа и конверта +Статус документа SHALL вычисляться одной функцией общего кода: `done` ⇔ раскладка найдена ∧ (организация или ИНН) ∧ +период определён (дата отчёта или самая поздняя абсолютная дата колонок) ∧ все строки `ok` или `detail` ∧ нет +значений, строк и полей шапки с `needs_review` ∧ нет жёстких нарушений §7 вне допуска и округления ∧ нет +предупреждений документа уровня `error`; иначе `needs_check`. `DISCLOSURE` — `done` без условий о раскладке, строках и +проверках. Вход функции SHALL быть одинаковым у сервиса и ядра и не зависеть от источника: `document_type`, +`layout_found` (сервис — столбец кодов найден хотя бы у одного сегмента; ядро — у формы есть строка из источника, +`row_index` не `null`), `has_organization_or_inn`, `period_known`, строки `{line_status, needs_review}`, значения +`{line_status строки, needs_review}`, число жёстких нарушений вне допуска и округления, `accepted`, уровни +предупреждений документа (у ядра — записи `document_issue` вида `warning`). `needs_review` на входе уже учитывает правки +и подтверждения аналитика (правленое или подтверждённое значение и строка — `false`); `accepted` — жёсткие нарушения не +учитываются. Значения строк `detail` функция MUST NOT учитывать, даже если на входе у них `needs_review = true` (Р-23). Статус ОСВ — +`done`, когда контрольные суммы §1.4 листа сошлись. Статус конверта: `done` ⇔ +все документы `done` ∧ нет предупреждений файла уровня `error`; иначе `needs_check`; документов нет — `error` +(`RECOGNITION_FAILED` у scan-gateway, `INVALID_DOCUMENT` у tabular-parser). Квартал периода, сверка ИНН и наименований +со справочником в `done` MUST NOT входить. + +#### Scenario: Excel-форма без замечаний +- **WHEN** Excel-ББ с найденной раскладкой, организацией, датой, всеми строками `ok`, сошедшимися соотношениями +- **THEN** документ `done`; уверенность 100 у Excel выполняет условия покрытия автоматически + +#### Scenario: Строка без кода с суммой +- **WHEN** в форме есть строка `unreadable` с суммой +- **THEN** документ `needs_check` + +#### Scenario: Ядро считает статус той же функцией +- **WHEN** ядро после PATCH вызывает функцию статуса по строкам БД формы, у которой все строки `ok`, правленая ячейка + была `needs_review`, нарушений и предупреждений `error` нет +- **THEN** функция даёт `done` — тот же результат, что дал бы сервис по тем же данным с подтверждённой ячейкой + +#### Scenario: Соседний документ не тянет файл +- **WHEN** ББ `done`, ОФР того же файла `needs_check` из-за одной ячейки, предупреждений файла нет +- **THEN** конверт `needs_check`, у ББ `document.status = done` + +#### Scenario: Сомнительная ячейка расшифровки +- **WHEN** у формы все строки `ok` или `detail`, нарушений и `error` нет, единственное значение с уверенностью ниже 100 — + в строке `detail` +- **THEN** документ `done` + +### Requirement: Точка входа пакета для пересчёта по сохранённым данным +Пакет SHALL предоставлять функцию пересчёта формы по сохранённым данным без сетки и `FormCandidate` — ею ядро +пересчитывает форму после PATCH и при приёме результата `1.x`, сервисы ею не пользуются. Вход — тип пакета +`StoredForm`, собираемый хостом из своих данных: +- `document_type`; `effective_date` (дата отчёта, иначе самая поздняя абсолютная дата колонок — у ядра + `document.reporting_date`); `has_organization_or_inn`; `period_known`; `needs_review_fields` (как хранит ядро: поле + уходит из него только правкой или подтверждением даты отчёта — сам пересчёт поля шапки не переоценивает); `accepted`; + `warnings` — предупреждения документа `{code, severity}` (у ядра — записи `document_issue` вида `warning`); +- `columns[] {key, column_index, period_date, period_kind}` (`key` — идентификатор хоста, у ядра `column_id`); +- `lines[] {key, row_index | null, line_status, line_code, line_code_confidence, line_name, code_fixed, parent_key, + values[] {column_key, amount, text, confidence, is_recognized, value_fixed}}`: `row_index = null` — строка, созданная + правкой (участвует в §7, как строка `ok`, пока у неё есть сумма); `code_fixed` — код назначен, сменён или подтверждён + аналитиком (`line_code_modified` или `is_confirmed` строки); `value_fixed` — значение правлено или подтверждено + (`is_modified` или `is_confirmed`); `text` — сырой текст источника (`source_text`), по нему считается форма знака; + у значения, созданного правкой, `text = null`. + +Выход — `{form_variant, edition, checks {relations_failed, soft_relations_failed}, line_needs_review {key → bool}, +value_needs_review {key → bool}, detail_parents {key → parent_key | null}, status}`. Правила — те же шаги 6–8, что у +сервисов, с поправками: `form_variant` определяется заново по кодам строк `ok` (код мог смениться правкой); `edition` — +редакция с лучшей подпоследовательностью кодов документа по всем строкам `ok` (как в структурной проверке, при +равенстве — по `effective_date`; кодов нет — по `effective_date`); `value_fixed` ⇒ `needs_review = false`, такое значение +в голосование знака не входит; `code_fixed` ⇒ `needs_review` строки = `false`; значения строк `detail` — `false`; +`accepted` ⇒ жёсткие нарушения статус не держат; `detail_parents` — ближайшая строка `ok` выше по `row_index` (строки +`row_index = null` родителями не бывают), нет такой — `null`. Пересчёт не меняет `line_status`, суммы и коды — только +выводит признаки. + +#### Scenario: Пересчёт после правки по строкам БД +- **WHEN** ядро передаёт форму: ячейка 1230 правлена (`value_fixed`), код 1250 назначен строке бывшей `unreadable` + (`code_fixed`), шаблонная строка 1260 с суммой (`row_index = null`), строка `detail` с уверенностью 60 +- **THEN** `checks` посчитаны с 1250 и 1260 в `1200 = …`; у 1230 и у значения `detail` `needs_review = false`, у строки + 1250 `needs_review = false`; `status` — по общей функции статуса; `form_variant` и `edition` — по кодам после правки + +#### Scenario: Смена кода меняет вариант +- **WHEN** у упрощённого по структуре ББ (есть 1600, нет 1100 и 1200) аналитик назначает строке код 1100 +- **THEN** точка входа возвращает `form_variant = full`, нарушения считаются полным набором diff --git a/openspec/changes/unified-statement-normalization/specs/tabular-recognition-service/spec.md b/openspec/changes/unified-statement-normalization/specs/tabular-recognition-service/spec.md new file mode 100644 index 0000000..ad8ad43 --- /dev/null +++ b/openspec/changes/unified-statement-normalization/specs/tabular-recognition-service/spec.md @@ -0,0 +1,66 @@ +## ADDED Requirements + +### Requirement: Адаптер Excel шага 1 +tabular-parser SHALL оставить у себя чтение книги (лимиты `PARSE_MAX_BYTES`, `PARSE_TIMEOUT_SECONDS`, формат по +сигнатуре, лимиты zip и объединённых ячеек, `UNREADABLE`), классификацию листов (ОСВ — отдельный путь; форма; +продолжение; прочее) и адаптер «блок формы на листе → `FormCandidate`» общего кода; шаги 2–8 по формам SHALL +выполняться копией пакета `arm_statements`. Адаптер SHALL: выделять блоки форм на листе по заголовку или ОКУД (без +кодов между ними блоки сливаются, заголовок после кодов — новая форма); собирать шапку каждого блока в его границах +(организация, ИНН без ИНН аудитора, ОКПО, ОКОПФ, ОКФС, единицы, дата, адрес) как `Cell` с `confidence = 100`; +заполнять у каждой ячейки `text` по числовому формату (tp#42) и `value`, `ref = <лист>!<A1>` (имя листа в одинарных +кавычках по правилу Excel, если в нём есть что-то кроме букв, цифр и `_`); дописывать +листы-продолжения в сетку открытой формы до раскладки (маркер «Форма … с.2» или позиция листа; проверки типа, +диапазона кодов и, при трёх и более кодах, числа колонок — как `join_blocker`), записывая границы листов в +`segment_breaks`. Несовпадение формата с `file_type` — предупреждение файла `FILE_TYPE_MISMATCH` (`info`). Лист без +признаков ОСВ и формы, без `type_hint` и кодов — предупреждение файла `SHEET_OTHER` (`info`, `locator = sheet:<имя>`), +документа нет. + +#### Scenario: Книга ББ + ОФР +- **WHEN** книга содержит лист «Баланс» (ОКУД 0710001), лист «Баланс (2)» с маркером «Форма 0710001 с.2» и лист «ОФР» +- **THEN** адаптер выдаёт два кандидата: `BALANCE` с `locator = sheet:Баланс,Баланс (2)` и одной сеткой из двух листов + (`segment_breaks` — первая строка второго листа) и `INCOME` с `locator = sheet:ОФР` + +#### Scenario: Ссылки ячеек +- **WHEN** код строки стоит в ячейке C27 листа «Баланс» +- **THEN** `ref` строки — `Баланс!C27`; у листа «Бухгалтерский баланс. Лист 1» — `'Бухгалтерский баланс. Лист 1'!C27` + +### Requirement: Строки без кода и неизвестные колонки в Excel +tabular-parser SHALL отдавать строки без кода с содержимым со статусами `detail` / `missing` / `unreadable` (Р-8; ранее +не отдавались, OQ-36 / OQ-141), числовые колонки с нераспознанной подписью — колонкой без даты и `error` (ранее +пропускались), повторы кодов — всеми строками без `duplicate_line_codes`. + +#### Scenario: НПЗ «Реализация иного имущества» +- **WHEN** в ОФР ФСБУ 4/2023 строка «Реализация иного имущества…» без кода с суммами стоит под строкой 2340 +- **THEN** в результате строка `detail` с `parent_ref` строки 2340; ранее она отбрасывалась со счётчиком + +### Requirement: Манифест ожиданий на реальных формах +Манифест `tabular-parser/api/tests/statements_manifest.yaml` SHALL быть обновлён в PR перехода на 2.0 по правилу +«посчитанного» соотношения §7 (пустая цель при непустых членах — нарушение): `failed_relations` страниц 2 баланса без +1600 получают `["1600 = 1700"]` — `unagrande-co/2024-FY/Б.Б. Лист 2.xls`, `unagrande-group/2024-FY/ББ. Лист 2 (2).xls`, +`unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` (было `[]`, причина «страница 2 без шапки» остаётся); страницы 1 «только +актив» (`Б.Б Лист 1.xls`, `ББ. Лист 1 .xls`, `ББ. Лист 1 3кв.25.xls`) сохраняют `["1600 = 1700"]` (теперь как «непустая +цель при пустых членах»). Статусы `done` (Межениновская ББ и ОФР, НПЗ ББ и ОФР, ОФР «Лист 1» Унагранде ×2) MUST NOT +меняться: их строки без кода уходят в `detail`, строки шапки выше заголовка — в `title`. Остальные записи манифеста +пересчитываются прогоном и правятся только с объяснением в описании PR. + +#### Scenario: Страница 2 баланса +- **WHEN** разбирается `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` (есть 1700, нет 1600) +- **THEN** документ `needs_check`, в `checks.relations_failed` — `1600 = 1700` с пустой целью + +### Requirement: Путь ОСВ в контракте v2.0 +У `osvDocument` tabular-parser SHALL выдавать `status` (`done`, когда контрольные суммы §1.4 листа сошлись, иначе +`needs_check` — прежние условия §1.6, применённые к листу) и `warnings[]` — прежние предупреждения листа структурами с `severity = info` и кодом с +префиксом `OSV_`; строк вида `sheet=<имя> <текст>` в `metrics.warnings` больше нет. Остальной разбор ОСВ не меняется. + +#### Scenario: Предупреждение ОСВ +- **WHEN** на листе ОСВ есть строка без родителя +- **THEN** у документа ОСВ предупреждение `{code: "OSV_…", severity: "info", message}`, статус документа по §1.6 + +### Requirement: Статус книги +Статус конверта tabular-parser SHALL вычисляться по общему правилу: `done` ⇔ все документы `done` ∧ нет предупреждений +файла уровня `error`; книга без листов ОСВ и форм — `error INVALID_DOCUMENT`. Порядок документов — сначала ОСВ в +порядке листов, затем формы. + +#### Scenario: Книга только из пояснений +- **WHEN** книга содержит только листы пояснений (0710003, «Пояснения») +- **THEN** документы `DISCLOSURE` со статусом `done`, конверт `done` diff --git a/openspec/config.yaml b/openspec/config.yaml index 96ea222..f1aa814 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -1,12 +1,12 @@ schema: spec-driven context: | - Репозиторий arm-ka-docs — контекст, модель данных (DBML v5.18), правила домена, контракты между сервисами + Репозиторий arm-ka-docs — контекст, модель данных (DBML v5.19), правила домена, контракты между сервисами (docs/contracts/*.schema.json), макет UI и экспорт ТЗ системы АРМ КА (ДКБ, Россельхозбанк). Здесь оформляются СКВОЗНЫЕ изменения: контракт + окружение разработки (docs/dev) + проверки качества (make check) + изменения документов контекста. Код сервисов живёт в соседних репозиториях (../arm-core, ../tabular-parser, ../scan-gateway), у каждого свой openspec/ и свой change; сквозной change ссылается на них. - Источники истины по приоритету: docs/model/data-model-v5.18.dbml → docs/model/entities-v5.18.md → + Источники истины по приоритету: docs/model/data-model-v5.19.dbml → docs/model/entities-v5.19.md → docs/context/05-domain-rules.md → docs/source/tz (черновик, имена v4). Открытые вопросы — docs/context/07-open-questions.md; план срезов — docs/context/09-delivery-plan.md; соглашения шаблона команды — docs/context/03-architecture.md. Язык документов и коммитов — русский, идентификаторы — английские, как в DBML. docs/source/** не редактируется. diff --git a/repo-templates/arm-core.CLAUDE.md b/repo-templates/arm-core.CLAUDE.md index 1887ac5..4d6cca4 100644 --- a/repo-templates/arm-core.CLAUDE.md +++ b/repo-templates/arm-core.CLAUDE.md @@ -16,7 +16,7 @@ - NATS subjects и схема ключей S3 — `../docs/context/03-architecture.md`. ## Правила репозитория -- ORM 1:1 с `../docs/model/data-model-v5.18.dbml`; `scripts/check_dbml_vs_orm.py` — обязательная проверка CI. +- ORM 1:1 с `../docs/model/data-model-v5.19.dbml`; `scripts/check_dbml_vs_orm.py` — обязательная проверка CI. - Алгоритмы — только из `../docs/context/05-domain-rules.md`; номер § — в docstring. - Вычисляемые объекты консолидации — `backend/app/domain/`, без ORM-таблиц. - Фронт: пользователь слаб во фронте — для TS/React изменений делай ревью сам и объясняй решения в PR. diff --git a/scripts/gen_model_diagrams.py b/scripts/gen_model_diagrams.py index 39116d8..9a453fb 100644 --- a/scripts/gen_model_diagrams.py +++ b/scripts/gen_model_diagrams.py @@ -37,7 +37,13 @@ PARTS = { "05_osv_parsed": ("Разобранные ОСВ", ["osv_entry"]), "06_statements": ( "Распознанные формы ББ/ОФР и логические проверки", - ["financial_statement_meta", "financial_statement_column", "financial_statement_line", "financial_statement_value"], + [ + "financial_statement_meta", + "financial_statement_column", + "financial_statement_line", + "financial_statement_value", + "document_issue", + ], ), "07_consolidation": ( "Консолидация: версии, статусы, периметр, документы, блокировка", diff --git a/scripts/validate_contracts.py b/scripts/validate_contracts.py index a34c2d6..e811fb3 100644 --- a/scripts/validate_contracts.py +++ b/scripts/validate_contracts.py @@ -1,14 +1,18 @@ #!/usr/bin/env python3 -"""Валидация примеров контракта распознавания схемами JSON Schema 2020-12. +"""Валидация примеров контракта распознавания схемами JSON Schema 2020-12 и упорядоченных перечней кодов форм. Запуск из любого cwd: python scripts/validate_contracts.py -Зависимости: стандартная библиотека + jsonschema. +Зависимости: стандартная библиотека + jsonschema + PyYAML (`make venv`). Код возврата: 0 — всё валидно, 1 — любая ошибка. + +Окно перехода на MAJOR 2 (change unified-statement-normalization): схемы прежнего MAJOR лежат рядом как +`*.v1.schema.json`, их примеры — в `examples/v1/`; удаляются после перехода обоих сервисов (маппер 1.x ядра — тоже). """ import datetime import json import re import sys +from collections import Counter from pathlib import Path from jsonschema import Draft202012Validator, FormatChecker @@ -21,8 +25,16 @@ SCHEMAS = { "request": CONTRACTS / "recognition-request.schema.json", "message": CONTRACTS / "recognition-result-message.schema.json", "result": CONTRACTS / "recognition-result.schema.json", + "message.v1": CONTRACTS / "recognition-result-message.v1.schema.json", + "result.v1": CONTRACTS / "recognition-result.v1.schema.json", } +# Каталог примеров → суффикс вида схемы: examples/ — текущий MAJOR, examples/v1/ — прежний (окно перехода). +EXAMPLE_DIRS = [(EXAMPLES, ""), (EXAMPLES / "v1", ".v1")] + +CODE_ORDERS = CONTRACTS / "statement-code-orders.yaml" +CODE_RE = re.compile(r"^\d{4}$") # в перечне только четырёхзначные коды (пятизначные — через родителя) + # Порядок важен: result-message-* проверяется раньше result-*. PATTERNS = [ ("request-", "request"), @@ -75,9 +87,11 @@ def main(): print(f"OK схема {path.name}") counts = {kind: 0 for kind in SCHEMAS} - for example in sorted(EXAMPLES.glob("*.json")): - kind = next((k for prefix, k in PATTERNS if example.name.startswith(prefix)), None) - if kind is None: + examples = [(example, suffix) for folder, suffix in EXAMPLE_DIRS for example in sorted(folder.glob("*.json"))] + for example, suffix in examples: + base = next((k for prefix, k in PATTERNS if example.name.startswith(prefix)), None) + kind = None if base is None else (base if base == "request" else base + suffix) + if kind is None or kind not in SCHEMAS: print(f"FAIL {example.name}: имя не подходит ни под один шаблон (request-*, result-message-*, result-*)") failed = True continue @@ -107,10 +121,81 @@ def main(): print(f"FAIL нет ни одного примера для схемы {SCHEMAS[kind].name}") failed = True + failed = check_code_orders() or failed total = sum(counts.values()) print(f"Итого: примеров {total}, {'есть ошибки' if failed else 'всё валидно'}") return 1 if failed else 0 +def code_order_errors(data): + """Структура перечня: editions[id] → forms[BALANCE|INCOME] → variants[full|simplified] → список кодов.""" + errors = [] + if not isinstance(data, dict) or not isinstance(data.get("editions"), dict): + return ["нет словаря editions"] + for edition in ("66n", "fsbu4"): + forms = data["editions"].get(edition) + if not isinstance(forms, dict): + errors.append(f"нет редакции {edition}") + continue + for form, first in (("BALANCE", "1"), ("INCOME", "2")): + variants = forms.get(form) + if not isinstance(variants, dict): + errors.append(f"{edition}: нет формы {form}") + continue + for variant in ("full", "simplified"): + codes = variants.get(variant) + where = f"{edition}/{form}/{variant}" + if not isinstance(codes, list) or not codes: + errors.append(f"{where}: пустой или не список") + continue + for code in codes: + if not isinstance(code, str) or not CODE_RE.fullmatch(code) or not code.startswith(first): + errors.append(f"{where}: код {code!r} — не строка из 4 цифр формы") + errors.extend(repeat_errors(where, codes, data.get("repeats", {}).get(edition, {}).get(form, []), variant)) + return errors + + +def repeat_errors(where, codes, repeats, variant): + """Законные повторы (Р-20): в варианте full код встречается ровно 1 + (число его вхождений в repeats) раз; + в simplified повторы допустимы только из repeats и не чаще, чем в full; код из repeats, которого нет в full, — ошибка.""" + errors = [] + counts = Counter(c for c in codes if isinstance(c, str)) + allowed = Counter(repeats) + for code in sorted(set(counts) | set(allowed)): + expected = 1 + allowed[code] + actual = counts[code] + if variant == "full": + if actual != expected: + errors.append(f"{where}: код {code} встречается {actual} раз, а по repeats должен {expected}") + elif actual > 1 and actual > expected: + errors.append(f"{where}: повтор кода {code} ({actual} раз) вне перечня repeats") + return errors + + +def check_code_orders(): + """Упорядоченные перечни кодов форм (05-domain-rules.md §11.9): yaml разбирается и устроен как описано.""" + if not CODE_ORDERS.is_file(): + print(f"FAIL {CODE_ORDERS.name}: файл не найден") + return True + try: + import yaml + except ImportError: + print(f"FAIL {CODE_ORDERS.name}: нужен PyYAML (make venv)") + return True + try: + data = yaml.safe_load(CODE_ORDERS.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + print(f"FAIL {CODE_ORDERS.name}: не YAML: {str(exc).splitlines()[0]}") + return True + errors = code_order_errors(data) + if errors: + print(f"FAIL {CODE_ORDERS.name}") + for error in errors: + print(f" {error}") + return True + print(f"OK {CODE_ORDERS.name}") + return False + + if __name__ == "__main__": sys.exit(main())