Andrey Epifancev 10317ae4d3 docs: итоги исполнения единой нормализации ББ/ОФР (#124, #116)
Решения дорожек (планы 3А, 3Б, 4, 5, 6) перенесены в design.md change'а,
добавлен раздел «Открытые вопросы исполнения»; гайд фронта по API приведён
к arm-core main 35a24e7a; уточнена строка про зависимости в dev/checks.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:57:03 +04:00

1729 lines
373 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Единый алгоритм нормализации ББ/ОФР (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. Файл пишется во «Входящие» горячей папки как `<job_id>.<ext>` (нет расширения / папка недоступна —
`RECOGNITION_FAILED`).
2. ИС кладёт в «Исходящие» `<job_id>/` один или несколько 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:<json>;table:<i>`. Шапка — поля json (общая).
**tabular-parser** (`statement._document`, `merge`): лист-продолжение присоединяется к последней открытой форме,
если число колонок сумм совпало и тип в маркере не противоречит (слабое продолжение с 1–2 кодами — ещё и коды из
диапазона формы); не присоединился — одинокое продолжение с пустой шапкой и предупреждение. `locator =
sheet:<A>,<B>`. `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`. Предупреждения —
плоские строки `<json>: table:<i>: <текст>` в `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 | Формат предупреждений | `<json>: table:<i>: …` | `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:<job_id>:<attempt>`.
У шлюза в конверте ещё `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:<i>` (как в §11 и golden-эталонах) / `sheet:<A>[,<B>]`;
- `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:<row_index>`,
Excel — `<лист>!<A1>`. Уникальность в документе требуется от `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:<i>#<k>`), ячейка кода, `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:<i>#<k>`; разлепление делается, если хотя бы одна часть кода разобралась как код диапазона столбца («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:<i>` / `sheet:<A>[,<B>]` — ключи как сейчас, 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. Формат предупреждений** — структура (см. выше) вместо строк `<json>: table:<i>: …` / `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…Р-44 — 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` по правилу, которое спека считает законным, исполнитель останавливается и поднимает вопрос
владельцу по конкретному случаю; адаптер под файл не подгоняется.
- **Р-44** (план 4, ответ на Р-31; 18.10.2026) — числа правее таблицы формы не колонки сумм: при наличии колонки с
подписью периода столбец без текста подписи правее последней такой колонки в значения не попадает, вместо
`COLUMN_WITHOUT_DATE` — `NUMBERS_OUTSIDE_TABLE` (`info`, адреса ячеек), статус не меняется (пример — Межениновская
2025-Q3, формулы аналитика в U–W). Уточняет P1-17 (№ 120). Цена ошибки: настоящая колонка сумм с потерянной подписью
будет отброшена с `info`; поэтому подписанный столбец без чисел границей не служит. Единые для Excel и OCR.
- **Р-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:<row_index>[#<k>]`, Excel `<лист>!<A1>`; `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:<i>#<k>` с 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<M>` сверяется с
`<имя>.v<M>.schema.json` docs, **если такой файл есть**, иначе с основным; пример — с `examples/v<M>/<имя>.json`, если
он есть, иначе с `examples/<имя>.json` (`M` — `MAJOR` копии схемы вида примера, вид — по префиксу имени); прочие файлы
копии (`.v<N>`, `examples/v<N>/` у ядра) — с одноимёнными; файлы 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<N>.schema.json` или `examples/v<N>/*.json`; тогда все такие файлы docs обязательны в копии. Цена: ядро, положившее один файл `.v1`, обязано положить все.
100. **P0-3. Неопределимая пара — ошибка**: пример с неизвестным префиксом, схема без `$id` вида `…/v<M>`, пример вида, схемы которого нет в копии — ошибка теста с именем файла, а не пропуск. Цена: новый вид примера в 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 фичи.
### Решения при исполнении (18.10.2026)
Перенос из `design.md` change'ов исполнения (разделы «Decisions made without discussion»): текст решений, обоснований и цен ошибки — как в источнике, без сокращений; маркеры источника сохранены дословно, нумерация docs продолжает общую (по подразделам ниже — по дорожкам). Номер в начале пункта — номер в этом файле. Решения владельца (Р-n) — в разделе «Решения владельца» выше; решения плана 0–2 и Р-44 перенесены раньше.
#### План 3, часть А (arm-core, change `unified-normalization-core-a`)
Источник: `openspec/changes/unified-normalization-core-a/design.md` в репозитории `arm-core`, коммит `c2ee31d` (ветка исполнения; полный текст — там). Перенесено пунктов: 107.
169. **P3-1** (план 3А): Миграция пишет `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 сырыми нет. Цена ошибки: подсказка у поля старой формы беднее; формы старых времён на контуре удалены очисткой.
170. **P3-2** (план 3А): `organization_name_modified` — `false` у всех форм (миграция и оба маппера), не `NULL`. Обоснование: как `inn_modified` в маппере 1.x; API отдаёт `bool`. Цена ошибки: одна строка `UPDATE`.
171. **P3-3** (план 3А): `needs_review_fields`: маппер `1.x` пишет `[]`, миграция оставляет `NULL`, чтение `NULL` = `[]`. Обоснование: спека миграции поле не заполняет. Цена ошибки: нет.
172. **P3-4** (план 3А): Маппер `1.x` тоже хранит вторую форму того же типа (Р-10); формы одного типа и одной пары `period_year`, `period_quarter` получают `MAPPING_WARNING` «в файле есть другая форма <тип> за тот же период» и по D-6 — `NEEDS_CHECK` (аналог Р-18). Обоснование: частичный индекс v5.19 больше не заставляет пропускать; одно правило хранения для обоих MAJOR. Цена ошибки: O-1: если владелец хочет прежний пропуск — вернуть ветку `continue`.
173. **P3-5** (план 3А): Предупреждения маппинга листа ОСВ (повтор счёта, строки без родителя, нарастающая по данным Q1) пишутся ещё и в `document_issue` документа листа (`warning`, `MAPPING_WARNING`, `info`, `core`) при любом MAJOR; строки в `metrics.mapping_warnings` остаются. Обоснование: спека: `DocumentOut.issues` ОСВ — «вместе с предупреждениями маппинга ядра этого листа». Цена ошибки: нет.
174. **P3-6** (план 3А): `file_ordinal` объединённого документа `DISCLOSURE` / `OTHER` — индекс первого такого документа в `documents[]`. Обоснование: несозданный документ «номер не занимает». Цена ошибки: нет.
175. **P3-7** (план 3А): **Переопределено Р-40 (владелец, 18.10.2026): `CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` пишутся только для четырёхзначных кодов** (пятизначные подстроки Excel не дают `info`; закрывает O-3). Остальное — как ниже. `CODE_NOT_IN_DICTIONARY` и `CODE_NOT_IN_EDITION` — одна запись на код документа (`line_ids` — все строки `ok` с кодом), пятизначные коды тоже проверяются; при приёме `1.x` проверки ядра уровня `info` не пишутся. Обоснование: спека для `1.x` перечисляет только §7 и `MAPPING_WARNING`. Цена ошибки: O-3 (поток `info` у пятизначных).
176. **P3-8** (план 3А): После правки записи `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.
177. **P3-9** (план 3А): Результат `2.0`: значение с `column_index` вне колонок формы и повтор `column_index` колонки — первое остаётся, `MAPPING_WARNING`. Обоснование: FK значения на колонку; уникальность `(meta_id, column_index)`. Цена ошибки: нет.
178. **P3-10** (план 3А): Части `PATCH` применяются в порядке `lines` → `values` → `columns` → `header`; вся адресация (422) проверяется до первой записи; `UNKNOWN_CELL` по `line_code` — по кодам строк `ok` с учётом назначений `lines` этого запроса; один `line_id` дважды в `lines` — 422 `INVALID_LINE_PATCH`. Обоснование: атомарность и предсказуемость. Цена ошибки: нет.
179. **P3-11** (план 3А): `reference_name` — первое имя кода в редакции ответа (`edition` по дате); нет редакции или кода — `null`. Обоснование: спека: «первое имя кода в словаре»; `edition` ответа — по дате. Цена ошибки: нет (Р-29).
180. **P3-12** (план 3А): (= Р-35, решение владельца.) Очистка отказывает, только если документ формы упомянут в `ex_snapshot_item` или привязан к версии в статусе «Верифицирована» или дальше: `VERIFIED` или `ARCHIVED` с `ex_snapshot.source_version_id` = версия; привязки к версиям в работе, на согласовании, `REJECTED`, `CANCELLED` (и архивированным отменённым) снимаются, статус версий не меняется. Обоснование: решение владельца 18.10.2026 (Р-35): верифицированная версия read-only и заморожена в ЕХ, прочие — нет; на стенде версий дальше «в работе» нет. Цена ошибки: архивированная версия без снимка ЕХ (из `CANCELLED`) очистке не мешает — если бы мешала, владелец увидел бы лишний отказ.
181. **P3-13** (план 3А): Коды выхода `purge_statements.py`: `0` — отчёт или выполнено, `1` — отказ (п. 2 спеки), `2` — ошибка аргументов. Обоснование: как у `check_dbml_vs_orm.py`. Цена ошибки: нет.
182. **P3-14** (план 3А): До части Б §7 считает ядро на лету (`statement_checks.yaml`) по агрегату; `checks.failed[].codes` берутся из правила, `line_ids` — по агрегату. Обоснование: часть А должна быть самосогласованной. Цена ошибки: временный код удаляется в B6.
183. **P3-15** (план 3А): Для условия `RECOGNIZED` `2.0` предупреждения файла берутся из `metrics.warnings` файла результата; в `recognition_job.metrics.warnings` — из конверта (inbox). Обоснование: маппер читает файл результата; по контракту наборы одинаковы. Цена ошибки: нет.
184. **P3-16** (план 3А): (= Р-34, решение владельца.) Подтверждение кода (`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 приведены к этому. Цена ошибки: нет.
185. **P3-17** (план 3А): Порядок строк ответа: строки `ok` (с шаблонными) — по коду, затем `row_index` (`NULL` последними), затем `id`; за каждой строкой `ok` источника — её «вложения» по `row_index`: строки `detail` с этим родителем и строки `unreadable` / `missing` / `detail` без родителя, у которых она — ближайшая предыдущая по `row_index` строка `ok` источника; вложения без такой строки — в начале. Обоснование: спека «Ответ `GET …/statement`». Цена ошибки: нет.
186. **P3-18** (план 3А): **Уточнено Р-41 (владелец): смена `crm_id` пишется в аудит как изменение группы (`GROUP_CHANGE`) — закрывает O-6.** Изменение `crm_id` ГСК/ГСЗ пишет одно событие `GROUP_CHANGE` с `before/after` всех изменённых полей вместо `MEMBERSHIP_CHANGE`. Обоснование: спека: «изменение — аудит `GROUP_CHANGE`». Цена ошибки: O-6.
187. **P3-19** (план 3А): Ядро зовёт у пакета `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 сравнивает их.
188. **P3-20** (план 3А): `header.source.<поле>.confidence` — строка Decimal с двумя знаками (`"61.54"`), как прочие уверенности API; в JSONB `header_source` остаётся число как пришло. Обоснование: фронт читает все уверенности одним способом (план 6, A8). Цена ошибки: округление до сотых в подсказке; исходное число — в БД.
189. **P3-21** (план 3А): `StatementHeaderOut.okei` — из `financial_statement_meta.okei` (колонка есть с v5.18), только чтение; в `PATCH header` не входит. Обоснование: спека `header` контракта несёт `okei`, экран показывает `issues` с `field = "okei"` у поля. Цена ошибки: нет.
190. **P3-22** (план 3А): Справочник строк для подсказки кода — поле `StatementOut.edition_lines [{code, name}]`, а не отдельный эндпоинт: данные уже загружены (`LineCatalog`, A6) при сборке ответа, нет нового маршрута, права, кэша и запроса на фронте; около 80 пар на форму (≈ 4 КБ) против строк формы с значениями; у формы без даты — коды всех редакций, по одному. Обоснование: наименьший объём реализации в ядре и во фронте; закрывает пробел «у формы без даты подсказки нет» (план 6, O-3). Цена ошибки: ответ `GET` тяжелее на ≈ 4 КБ; если понадобится справочник вне формы — отдельный эндпоинт поверх того же `LineCatalog.edition_lines`.
191. **P3-23** (план 3А): `DocumentBrief.issue_counts {error, info}` — все записи `document_issue` документа (и `relation` / `soft`, в том числе акцептованные) по `severity`; один групповой запрос на файл. Обоснование: панели файлов нужен признак «есть что смотреть» без запроса на каждый документ (план 6, O-4). Цена ошибки: акцептованное нарушение продолжает помечать документ в панели — подробности на экране документа.
192. **P3-24** (план 3А): Ошибки элемента `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); одно правило для всех ошибок элемента. Цена ошибки: при нескольких неверных элементах фронт видит только первый — как и прежде.
193. **R-1** (план 3А): Порядок исполнения части А: A0–A4, затем A6–A13; A5 (контракт 2.0, `recognition_exchange.py`) — последней, после слияния плана 0 (в `origin/main` нет `api/tests/contract_identity.py`). Обоснование: A5 зависит от теста идентичности плана 0, остальные задачи его результат не потребляют. Цена ошибки: возможные конфликты слияния в `documents.py` / `recognition_exchange.py` при A5 в конце.
194. **R-2** (план 3А): Исполнители задач и их ревьюеры — sonnet; финальное ревью ветки — opus. Обоснование: план даёт прозу, а не готовый код; ревью ветки ловит сквозные расхождения. Цена ошибки: более слабая модель на задаче — лишние итерации ревью.
195. **R-3** (план 3А): Тесты с БД идут на scratch-БД `armcore_pytest_*` compose-Postgres :5432 (устройство conftest); контейнеры не останавливаются и не пересоздаются. Обоснование: общий стенд разработки не должен падать из-за прогонов. Цена ошибки: недоступный Postgres — задача BLOCKED, а не пропуск тестов.
196. **R-4** (план 3А): A0 (только артефакты OpenSpec) без отдельного ревью сабагентом: достаточно `openspec validate --strict`. Обоснование: кода в задаче нет. Цена ошибки: неточность формулировок дельт; поймало финальное ревью.
197. **R-5** (план 3А): Ревью A1 и A2 объединено в один пакет после A2. Обоснование: downgrade-тесты A1 проверяемы только с A2. Цена ошибки: находки по A1 пришли на один раунд позже.
198. **R-6** (план 3А): Ревью малых задач объединялось парами (A3+A4, A8+A9, A10+A11, A12+A13). Обоснование: экономия раундов ревью. Цена ошибки: находки по первой задаче пары приходят на шаг позже.
199. **R-7** (план 3А): Change `unified-normalization-core-a` архивируется только вместе с `unified-normalization-core-b`; тексты поведения части Б в дельтах А остаются. Обоснование: разбор дельт на два change дороже и не нужен до выкладки обеих частей. Цена ошибки: если А заархивировать отдельно, `openspec/specs` опишет нереализованное.
200. **R-8** (план 3А): Одна волна правок по итогам финального ревью (sonnet), затем один scoped re-review; M-6, M-8, M-13 не правятся (deferred / rejected). Обоснование: замечания малы; M-13 (advisory-блокировки purge пофайлово) — окно обслуживания. Цена ошибки: M-6 (отдельный SUM для `code_amount`) и M-8 остаются техдолгом.
201. **A1-1** (план 3А): `result_mapping.map_file` до A5/A9 присваивает `file_ordinal` порядковым номером создания документов. Обоснование: иначе `uq_document_stored_file_id_file_ordinal` ломает файл с 2+ документами. Цена ошибки: номера не равны индексу в `documents[]`; заменено в A9/A5.
202. **A1-2** (план 3А): `organization_name_modified` — nullable boolean (как в DBML); миграция пишет `false`. Обоснование: соответствие DBML v5.19. Цена ошибки: нет.
203. **A1-3** (план 3А): Тест-фабрика `tests/factories.py` и `tests/test_models_part_04_06._document` нумеруют `file_ordinal`. Обоснование: уникальность `(stored_file_id, file_ordinal)`. Цена ошибки: нет.
204. **A1-4** (план 3А): Старый тест «две BALANCE с NULL -> IntegrityError» переписан под DISCLOSURE. Обоснование: по v5.19 для форм это больше не нарушение. Цена ошибки: нет.
205. **A1-5** (план 3А): Имена FK/PK новых объектов (`fk_financial_statement_value_confirmed_by_app_user`, `fk_document_issue_*`, `pk_document_issue`) — по NAMING_CONVENTION. Обоснование: совпадают с `create_all`, тест схемы зелёный. Цена ошибки: нет.
206. **A2-1** (план 3А): `line_status` считается потерянным при `<> 'ok'` (колонка NOT NULL default 'ok'). Обоснование: `IS NOT NULL` всегда истинно. Цена ошибки: шум в журнале отката.
207. **A2-2** (план 3А): Булевы счётчики и `organization_name_modified` считаются как `IS TRUE`; причина отказа «наименование правилось» — `organization_name_modified IS TRUE`. Обоснование: NULL не считается правкой. Цена ошибки: нет.
208. **A2-3** (план 3А): Причина «строки формы без кода» — по `line_code IS NULL` (любые), «повтор» — только по ненулевым кодам. Обоснование: точность причин отказа отката. Цена ошибки: нет.
209. **A2-4** (план 3А): Сообщение журнала склеивает счётчики через `; `. Обоснование: читаемость. Цена ошибки: нет.
210. **A3-1** (план 3А): Только PATCH, меняющий `crm_id`, пишет `GROUP_CHANGE` (`before/after` всех изменённых полей запроса); PATCH только имени/даты остаётся `MEMBERSHIP_CHANGE`; создание — `MEMBERSHIP_CHANGE` с `crm_id` в payload. Обоснование: P3-18 / Р-41 про смену `crm_id`; минимум изменения поведения. Цена ошибки: фильтр аудита по действию для правок групп расходится; правка в одну строку.
211. **A3-2** (план 3А): Предпроверка уникальности `crm_id` не учитывает видимость (глобальная уникальность), как для ГК. Обоснование: единый подход. Цена ошибки: `crm_id` невидимой группы раскрывает существование через 409 (как ГК/ЮЛ).
212. **A3-3** (план 3А): Before-payload удаления группы включает `crm_id`. Обоснование: полнота аудита. Цена ошибки: нет.
213. **A4-1** (план 3А): `run` принимает подменяемую фабрику транзакции (по умолчанию `session_transaction`). Обоснование: тесты идут на `db_session`. Цена ошибки: нет (боевой путь — по умолчанию).
214. **A4-2** (план 3А): `PurgePlan` несёт `include_mixed`; `BlockingVersion` по `ex_snapshot_item` имеет статус `EX_SNAPSHOT_ITEM` и `version_id = ex_snapshot.source_version_id`. Обоснование: сигнатура `apply_purge` без флага. Цена ошибки: нет.
215. **A4-3** (план 3А): Проверка блокировки покрывает ВСЕ документы удаляемых целиком файлов (в том числе OSV/DISCLOSURE; OSV смешанных файлов с `--include-mixed`). Обоснование: иначе удаление сломало бы FK/заморозку. Цена ошибки: лишний отказ, если OSV удаляемого файла в верифицированной версии (корректно: она заморожена).
216. **A4-4** (план 3А): `apply_purge` блокирует все файлы плана (jobs -> file -> documents, по id файла), перепланирует под блокировками и отказывает до первой мутации (`PurgeRefusedError`); версии блокируются позже (file -> doc -> version). Обоснование: защита от гонки с загрузкой. Цена ошибки: окно между проверкой и блокировкой версий (T4.3, rejected).
217. **A4-5** (план 3А): Dry-run с блокировкой печатает блок отказа, но выходит с 0; `--apply` с блокировкой: отчёт в stdout, сообщение в stderr, код 1. Обоснование: P3-13: 0 = отчёт. Цена ошибки: нет.
218. **A4-6** (план 3А): `FILE_DELETE.after_value`: для целого файла `{unbound_version_ids}`, для смешанного `{deleted_documents, unbound_version_ids}`. Обоснование: трассируемость очистки. Цена ошибки: нет.
219. **A4-7** (план 3А): `superseded_with_osv` — файлы, у которых `superseded_by_file_id` указывает на удаляемый файл и которые содержат OSV. Обоснование: сохранение замещения ОСВ. Цена ошибки: нет.
220. **A6-1** (план 3А): `section_number` возвращает строчные буквы (работает по нормализованному тексту). Обоснование: внутренний формат. Цена ошибки: нет.
221. **A6-2** (план 3А): `names()` — имена без повторов в порядке редакций и появления; `edition_codes` — по всем записям. Обоснование: стабильность подсказки. Цена ошибки: нет.
222. **A6-3** (план 3А): Если «итого по разделу» только у одного из имён, номер у другого `None` — сравнение только по равенству строк. Обоснование: спека. Цена ошибки: нет.
223. **A6-4** (план 3А): Пустые справочные имена пропускаются. Обоснование: шум. Цена ошибки: нет.
224. **A5-1** (план 3А): `FileWarning` наследует `DocumentWarning` (есть также `line_refs`/`column_index`/`field`): схема fileWarning = warning + locator. Обоснование: надмножество брифа, без дублирования полей. Цена ошибки: нет.
225. **A5-2** (план 3А): `MetricsV2` наследует `Metrics` (переопределяет `warnings`); ветвление в `_metrics_json` идёт по классу конверта, не метрик. Обоснование: изоляция v1/v2 при общей базе метрик. Цена ошибки: v2-конверт обернул бы уже готовые структуры; покрыто `test_inbox_v2_warnings_kept`.
226. **A5-3** (план 3А): `parse_message`/`parse_result`: не-2 MAJOR отдаётся модели v1, чей шаблон `^1\.\d+$` даёт `ValidationError` (3.0, «banana», нет поля); нечитаемый JSON в `parse_result` — `json.JSONDecodeError` (ValueError), не `ValidationError`. Обоснование: единая точка отказа для неподдерживаемых версий. Цена ошибки: вызывающий код, ждущий только `ValidationError`, пропустит ошибку чтения JSON (см. открытый вопрос).
227. **A5-4** (план 3А): У v2-предупреждения без `locator` в метрики пишется как пришло (ключа нет), у LEGACY — `locator: None`. Обоснование: не домысливать данные, не менять пришедшее. Цена ошибки: потребитель обязан читать `locator` как необязательный (`file_warnings` так и делает).
228. **A5-5** (план 3А): `RecognitionResultMessageV2` не наследует v1; общие поля вынесены в `_MessageBase`. Обоснование: изоляция `isinstance` между версиями. Цена ошибки: нет.
229. **A5-6** (план 3А): `file_warnings`: `locator` не-строка/не-null -> объект пропускается. Обоснование: защита от мусора во входящем конверте. Цена ошибки: потеря предупреждения с некорректным locator.
230. **A6-5** (план 3А): Старые тесты API обновлены под код 1105 в `fsbu4` (yaml дополнен 1105/1130/1140/1530/2460 и вторыми именами). Обоснование: данные docs. Цена ошибки: для ГСК-кода 1105 на 66н с датой `fsbu4` появляется пустая строка «Гудвил» (O-2 A6).
231. **A7-1** (план 3А): `FailedCheck.codes` — поле с default `()`, участвует в сравнении. Обоснование: P3-14. Цена ошибки: низкая.
232. **A7-2** (план 3А): `insert_statement` до маппера 2.0 пишет `row_index` = порядковый номер строки черновика. Обоснование: иначе все строки источника стали бы `origin = template`. Цена ошибки: заменено в A9/части Б.
233. **A7-3** (план 3А): `checks_input` принимает объекты-колонки (id, column_index) — протокол, без импорта моделей в домен. Обоснование: чистый домен. Цена ошибки: нет.
234. **A7-4** (план 3А): `code_amount`: колонка — первая по `column_index`, затем SUM по ней; нет колонки или непустых — `None`. Обоснование: определённость суммы. Цена ошибки: M-6: отдельный SUM, deferred в часть Б.
235. **A7-5** (план 3А): В API `checks.failed[].codes` и `line_ids` не выведены в A7 (только домен). Обоснование: не в брифе A7. Цена ошибки: закрыто в A8.
236. **A7-6** (план 3А): Порядок строк ответа в A7 не менялся. Обоснование: P3-17 — задача A8. Цена ошибки: нет.
237. **A8-1** (план 3А): `insert_statement` временно пишет `period_kind` (`absolute` при дате, иначе `none`). Обоснование: иначе тест брифа на 1.x не проходит до A9. Цена ошибки: заменено в A9; совпадает с backfill миграции.
238. **A8-2** (план 3А): `FailedCheckOut.line_ids` — объединение `line_ids` агрегата по `codes` правила; у межформенной проверки — строки только читаемой формы. Обоснование: кода второй формы в агрегате нет. Цена ошибки: фронт не подсветит строку другой формы.
239. **A8-3** (план 3А): `header.source`: запись с `text: null` отдаётся как есть, нечисловая уверенность -> `null`, не-dict записи пропускаются. Обоснование: T1.1: «нет подсказки». Цена ошибки: нет.
240. **A8-4** (план 3А): `order_lines`: `detail` с неизвестным родителем ведёт себя как `detail` без родителя; вложения по `row_index`; равные ключи — порядок входа (по `id`). Обоснование: P3-17. Цена ошибки: нет.
241. **A8-5** (план 3А): `IssueCounts` — dataclass репозитория, сервис переводит в `IssueCountsOut`. Обоснование: слои. Цена ошибки: нет.
242. **A9-1** (план 3А): `ColumnDraft` вместо кортежа в `StatementDraft.columns` (несёт `period_kind` / `label_confidence`). Обоснование: нужны поля колонки. Цена ошибки: правка 4 утверждений в тестах.
243. **A9-2** (план 3А): `header_source.*.confidence` в БД — число как пришло из 1.x (float), не Decimal. Обоснование: JSONB; P3-20. Цена ошибки: нет.
244. **A9-3** (план 3А): Повтор формы определяется только при известных годе и квартале у обеих; текст issue без префикса locator, в `metrics.mapping_warnings` — с префиксом. Обоснование: P3-4. Цена ошибки: нет.
245. **A9-4** (план 3А): Пустая ячейка (нет значения) -> `needs_review = false` (`value_clean(None, None)` истинно). Обоснование: формула приёма 1.x. Цена ошибки: нет.
246. **A9-5** (план 3А): У листа ОСВ issue пишутся и для предупреждения о повторе счёта (относится к первому листу). Обоснование: P3-5. Цена ошибки: нет.
247. **A10-1** (план 3А): Контрагент `UNKNOWN` трактуется как `None`, блокирует только `MISMATCH`. Обоснование: спека: `counterparty_check != MISMATCH`. Цена ошибки: низкая.
248. **A10-2** (план 3А): `name_mismatches`: строка `ok` с `line_code_confidence = None` считается уверенной (не проверяется); `line_status` сравнивается со `StatementLineStatus.ok` (StrEnum). Обоснование: спека: «confidence < 100» (T10.1: покрыто комментарием и тестом). Цена ошибки: строка без уверенности не даёт `NAME_MISMATCH`.
249. **A10-3** (план 3А): `has_error_warning`: строго `severity == "error"` (регистр важен), не список -> `False`. Обоснование: строгий контракт. Цена ошибки: нет.
250. **A10-4** (план 3А): `noqa PLR0913` на `form_status_v2` и тестовом хелпере. Обоснование: сигнатура задана планом. Цена ошибки: нет.
251. **A11-1** (план 3А): Тест `test_disclosure_is_confirmable` удалён: `form_blocker` принимает meta, у расшифровки её нет; не-форма проверяется вызывающими (FORM_TYPES) — **тест возвращён в волне правок (T11.1)** на уровне сервиса. Обоснование: покрытие подтверждения DISCLOSURE/OTHER. Цена ошибки: нет.
252. **A11-2** (план 3А): `confirmed_twin` при пустом периоде возвращает `None` (`PERIOD_REQUIRED` сработает раньше); первым выбирается близнец с меньшим `file_ordinal`. Обоснование: детерминизм. Цена ошибки: нет.
253. **A11-3** (план 3А): Дубль ищется только среди `CONFIRMED`; второй `NEEDS_CHECK`-близнец не блокирует. Обоснование: бриф. Цена ошибки: нет.
254. **A11-4** (план 3А): `line_id` в `extra` — строка (`str(uuid)`), как `document_id`. Обоснование: единообразие. Цена ошибки: нет.
255. **A12-1** (план 3А): Новый код 422 `DUPLICATE_COLUMN` (колонка дважды в `columns[]`). Обоснование: в брифе не было. Цена ошибки: низкая.
256. **A12-2** (план 3А): Подтверждение даты отчёта требует непустого `report_date` (null == null не подтверждение). Обоснование: смысл подтверждения. Цена ошибки: низкая.
257. **A12-3** (план 3А): Равное текущему `organization_name` — no-op. Обоснование: подтверждать нечего. Цена ошибки: нет.
258. **A12-4** (план 3А): Снятие подтверждения ячейки и возврат суммы к исходной: `needs_review` = не(правлена | подтверждена | строка detail | value_clean) — формула приёма 1.x. Обоснование: для 2.0 точное значение даст часть Б. Цена ошибки: ячейка 2.0 с иным сомнением (знак) может показаться чистой до Б.
259. **A12-5** (план 3А): `recompute` дополнительно сбрасывает `needs_review` у правленых/подтверждённых значений и значений строк `detail` (`settle_edited_values`). Обоснование: согласованность флагов. Цена ошибки: T13.3: флаг навсегда `false` у значений detail (часть Б).
260. **A12-6** (план 3А): Подтверждение ячейки без записи значения в БД — 422 `UNKNOWN_CELL` до записей; колонка не этой формы в `values[]` — `UNKNOWN_CELL`, `UNKNOWN_COLUMN` — только для `columns[]`. Обоснование: существующий тест. Цена ошибки: нет.
261. **A12-7** (план 3А): `is_current` пересчитывается только при смене дат колонок (самая поздняя дата, меньший `column_index`). Обоснование: как при приёме. Цена ошибки: нет.
262. **A12-8** (план 3А): Ключи аудита подтверждений: `value:<id>:confirmed`, `column:<id>:date_confirmed`, `needs_review_fields`; смена даты колонки — `column:<id>`. Обоснование: единый формат. Цена ошибки: нет.
263. **A12-9** (план 3А): Смена `report_date` убирает `report_date` из `needs_review_fields` без отдельного ключа аудита. Обоснование: минимум шума. Цена ошибки: нет.
264. **A12-10** (план 3А): `lines[]` в `StatementPatch` нет в A12 (добавлено в A13). Обоснование: разбиение задач. Цена ошибки: нет.
265. **A13-1** (план 3А): Тесты используют код 1215 вместо 1250 из брифа. Обоснование: 1250 есть в источнике ББ Межениновской, а нужен «только шаблонный» код. Цена ошибки: нет.
266. **A13-2** (план 3А): `tests/test_api_statements.py::insert_line` кладёт `StatementLineStatus`, а не строку. Обоснование: identity map сервиса. Цена ошибки: нет.
267. **A13-3** (план 3А): Пометка шаблонной строки (`row_index IS NULL`) как `detail` — 422 `INVALID_LINE_PATCH`; подтверждение кода у строки не `ok` — тоже. Обоснование: спека такого перехода не перечисляет; у `detail` нужен `row_index` для родителя. Цена ошибки: низкая, фронт такого не шлёт.
268. **A13-4** (план 3А): Назначение того же кода строке `ok` с этим кодом — no-op; `detail -> detail` — no-op. Обоснование: нечего менять, аудита нет. Цена ошибки: T13.1: no-op элемент может дать ложный 409 — недостижимо.
269. **A13-5** (план 3А): Порядок проверок по элементам: действие, повтор, `UNKNOWN_LINE`, `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, допустимость; затем отдельным проходом `LINE_CODE_TAKEN` (409 после всех 422). Обоснование: предсказуемость. Цена ошибки: нет.
270. **A13-6** (план 3А): `needs_review` при снятии подтверждения: `ok` и не `line_code_modified` и уверенность < 100. Обоснование: правило приёма 1.x; для 2.0 уточнит часть Б. Цена ошибки: нет.
271. **A13-7** (план 3А): Назначение кода: `confidence = Decimal("100.00")` (ответ отдаёт объект сессии). Обоснование: без перечитывания. Цена ошибки: нет.
272. **A13-8** (план 3А): Удаление пустой шаблонной строки пропускается, если она сама — цель элемента запроса. Обоснование: избежать удаления цели. Цена ошибки: нет.
273. **A13-9** (план 3А): **Отступление от P3-10:** 422 в `values` / `columns` после применённых `lines` откатывает всю транзакцию запроса; отдельной предпроверки адресации `values` для `lines` нет. `UNKNOWN_CELL` по `line_code` видит назначения `lines` этого запроса (они применены и flush-нуты раньше); 409 приоритетнее такого 422. Обоснование: эквивалентное наблюдаемое поведение при меньшем коде (T13.5). Цена ошибки: при ошибке в откате частичная запись — тест M-10 фиксирует отсутствие записей и аудита.
274. **A13-10** (план 3А): `user_id` в `apply_line_edits` принят по интерфейсу, не используется; аудит строк — ключ `line:<id>` с `{line_code, line_status, parent_row_index, is_confirmed}`, в него попадают и `detail` со сменой только родителя. Обоснование: у строки нет автора правки. Цена ошибки: нет.
275. **A13-11** (план 3А): Пометка шаблонной строки как `detail` = 422 `INVALID_LINE_PATCH`. Обоснование: см. A13-3. Цена ошибки: низкая.
#### План 3, часть Б (arm-core, change `unified-normalization-core-b`)
Источник: `openspec/changes/unified-normalization-core-b/design.md` в репозитории `arm-core`, коммит `0f84088` (ветка исполнения; полный текст — там). Перенесено пунктов: 40.
276. **R-1 (Р-40)** (план 3Б): `CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` пишутся только для кодов из ровно четырёх цифр; пятизначные подстроки Excel не проверяются. **P3-7 плана в этой части заменён** (запись на код документа сохраняется, пятизначные коды — нет). Обоснование: решение владельца 18.10.2026 (Р-40) перед исполнением Б; закрывает O-3 (поток `info` у пятизначных). Цена ошибки: пропущенный `info` у нестандартного пятизначного кода; восстановление — снять условие длины кода в маппере 2.0 и пересчёте.
277. **R-2 (Р-34)** (план 3Б): Подтверждение кода строки — флаг `is_confirmed`; `line_code_confidence` и `line_code_modified` не меняются (P3-16 части А). Обоснование: решение владельца 18.10.2026; в пересчёте Б правило `is_confirmed ⇒ needs_review строки = false` берётся из P3-16. Цена ошибки: нет — поведение уже реализовано в части А.
278. **R-3** (план 3Б): Удаление копии словаря §7 в дельте оформлено как MODIFIED «Соотношения §7 по колонкам», а не REMOVED (см. D-4); «Ответ `GET …/statement`» и порядок 409 оформлены новыми ADDED-требованиями, так как в `openspec/specs/` и дельтах части А одноимённых нет. Обоснование: `openspec archive` требует, чтобы имя REMOVED/MODIFIED существовало; иначе архив упадёт. Цена ошибки: при архивации — правка имён заголовков дельт.
279. **P3-25 (B1)** (план 3Б): Тип формы во входе пересчёта — `document.document_type` (`BALANCE` / `INCOME` / `DISCLOSURE`), а не `meta.statement_type` (только `balance` / `income`); план писал «тип формы» без источника. Обоснование: `DISCLOSURE` не представим в `statement_type`; тип документа — единственный источник для всех трёх типов. Цена ошибки: форма `DISCLOSURE` собралась бы с ошибкой `KeyError` / потерей типа; правка — одна строка в `build_stored_form`.
280. **P3-26 (B1, O-3 часть А)** (план 3Б): Копия `statement-code-orders.yaml` в `api/contracts/` не заводится: перечень кодов читает пакет из своей байтовой копии `api/arm_statements/rules/statement_code_orders.yaml` (`loader._ORDERS`), ядро файла не открывает; идентичность копии пакета с `docs/contracts/statement-code-orders.yaml` проверяет `sync-statements --check`; тест идентичности контракта сверяет только `*.json`. Сейчас копия пакета побайтно равна файлу docs. Обоснование: второй экземпляр файла в ядре никто бы не читал и он мог бы разойтись с пакетом. Цена ошибки: нет; если sync-statements когда-либо перестанет сверять YAML — добавить сверку в `contract_identity.py`.
281. **P3-27 (B1)** (план 3Б): `edition_codes` берёт строки `ok` результата `2.0`, сортирует по `row_index` и помечает код уверенным при `line_code_confidence == 100` (ровно), `None` — неуверенный. Обоснование: как в разделе «Что ядро вызывает» и в `recompute` пакета (код правленый/подтверждённый там — `code_fixed`). Цена ошибки: редакция приёма разойдётся с редакцией пересчёта; сверку делает `test_edition_codes_and_engine_edition`.
282. **P3-28 (B2)** (план 3Б): `StatementService` получает `StatementsEngine` через зависимость `get_statements_engine` (тесты подменяют её `FakeEngine`); `FailedRelation`, `StatementChecks`, `PeriodKind` реэкспортируются из `services/statements_engine.py`, чтобы `import arm_statements` оставался только в двух файлах. Обоснование: единственная точка импорта пакета (common.md); подмена без патчей. Цена ошибки: нет.
283. **P3-29 (B2)** (план 3Б): Статус после правки — `form_status_v2`: `service_done = (result.status == DONE)`, `file_error_warning` — `metrics.warnings` задания, `names_ok` — нет `NAME_MISMATCH` пересчёта, `duplicate_in_file` — `duplicate_period_forms` по формам файла (`file_form_periods`); `statement_status` D-6 с пути правки убран. Обоснование: один источник статуса с приёмом `2.0` (Р-16, Р-18, Р-19). Цена ошибки: форма с повтором периода в файле после правки остаётся `NEEDS_CHECK` — как при приёме.
284. **P3-30 (B2)** (план 3Б): Пустая цель при непустых членах — нарушение с `expected = NULL` (P1-21); тесты части А «очищенная 1700 не цель» и «цель потерянной строки не проверяется» переписаны под это; потерявшая строку 1700 форма блокирует подтверждение до правки или акцепта. Обоснование: следствие P1-21 пакета; спека («посчитанное соотношение»). Цена ошибки: форма, потерявшая итоговую строку, теперь требует действия аналитика — если владелец против, правится правило пакета (план 1), не ядро.
285. **P3-31 (B2)** (план 3Б): `NOTHING_TO_ACCEPT` и `CHECKS_NOT_PASSED` читают записи `relation`; акцептованная форма не считается нарушенной (`statement_hard_failures` пуст при `checks_accepted_at`), поэтому повторный акцепт — 409 `NOTHING_TO_ACCEPT`, как в части А. Обоснование: сохранение поведения А; буква брифа («нет записей `relation`») допускает оба прочтения. Цена ошибки: повторный акцепт с новым комментарием станет возможен, если снять условие.
286. **P3-32 (B2)** (план 3Б): «Эффективная дата» для `relative:*` колонок при правке `header.report_date` — новое значение `meta.report_date` (в том числе `NULL` ⇒ `period_date = NULL`), не `document.reporting_date`; изменение дат колонок идёт в аудит ключом `column:<id>` без признака правки данных. Обоснование: период документа сам выводится из даты отчёта и дат колонок (§9.5 п. 2), поэтому циклическая зависимость недопустима. Цена ошибки: колонка при пустой дате отчёта теряет дату; аналитик ставит её вручную.
287. **P3-33 (B2)** (план 3Б): Запись `relation` / `soft` несёт и `column_id` (колонка найдена по `column_key`: `str(column_id)`, затем `str(column_index)`), и `column_index` нарушения; ответ берёт индекс из колонки, иначе из записи, иначе `-1`. Обоснование: ключ колонки B4 для `2.0` — индекс. Цена ошибки: нет.
288. **P3-34 (B2)** (план 3Б): `NAME_MISMATCH` пересчитывается для всех кодов (R-1 ограничивает только `CODE_NOT_IN_*`); `CODE_NOT_IN_*` не пишутся, если пакет не определил редакцию (а `CODE_NOT_IN_EDITION` — и вариант); словарь имён берётся по редакции пакета, не по дате. Обоснование: R-1 и спека (редакция документа — `edition` пакета). Цена ошибки: пятизначная подстрока с уверенностью < 100 даёт `NAME_MISMATCH` (кода нет в словаре) — если поток окажется шумным, ограничить `name_mismatches` четырёхзначными.
289. **P3-35 (B2)** (план 3Б): Временный код A12/A13 удалён: тело `StatementService.recompute`, `settle_edited_values`, `has_review_values`, `has_source_lines`, `_value_needs_review`, `nearest_ok_parents` и `_recompute_parents`; `parent_row_index` `detail` пишет только пересчёт пакета. Обоснование: задание B2; T13.3 (флаги detail больше не затираются навсегда). Цена ошибки: нет.
290. **P3-36 (B3)** (план 3Б): `checks_on_intake_1x` живёт в `statement_recompute.py`; движок передаётся в `_insert_statements` необязательным параметром (по умолчанию `default_engine()`), `map_file` сигнатуру не меняет. Приём `1.x` пишет `meta.form_variant` и записи `relation` / `soft` (`core`), но не `CODE_NOT_IN_*` / `NAME_MISMATCH` (P3-7) и не трогает `needs_review` (A9); условие D-6 «проверки сошлись» — `not result.checks.relations_failed` (нарушение с `expected = NULL` — жёсткое). Обоснование: задание B3, спека «Пересчёт при приёме»; расхождение с прежним `run_checks` (его `failed`) возможно только на формах с пустой целью, где пакет строже. Цена ошибки: форма `1.x` с пустой целью получит `NEEDS_CHECK` вместо `RECOGNIZED`.
291. **P3-37 (fix1, T2.1)** (план 3Б): Аудит `lines[]` довносит смену `parent_row_index` после пересчёта: у правленных строк значение заменяется итоговым, у остальных появляется запись `line:<id>` с одним ключом `parent_row_index` (`{"parent_row_index": старый}` → `{…: новый}`). Обоснование: родителей пишет `recompute_form` уже после снимка строк правки; полный снимок для не правленных строк не нужен. Цена ошибки: в событии `RECOGNIZED_DATA_EDIT` у не правленных строк неполный набор ключей.
292. **P3-38 (fix1, T1.1/T1.2/T2.4/T2.6/T2.8)** (план 3Б): Паритет редакции приёма и пересчёта для строки с `line_code_confidence < 100` и `code_fixed` подтверждён тестом (расхождения нет, код не менялся); `edition_codes` пропускает строку `ok` без кода; `column_index` нарушения — общим помощником `issue_column_index` (запись, иначе колонка по `column_id`) в ответе и в `detail` отказа; `_unconfirmed_needs_review` и присвоения `needs_review` в `_apply_item` удалены (флаг пишет только пересчёт, он всегда идёт после правки строк); неизвестный `relative:*` в `_resolve_relative_columns` пропускается без ошибки. Обоснование: замечания ревью B1+B2. Цена ошибки: колонка с неизвестным `relative:*` не следует за датой отчёта.
293. **P3-39 (B4)** (план 3Б): Проверки ядра `info` (`CODE_NOT_IN_DICTIONARY`, `CODE_NOT_IN_EDITION`, `NAME_MISMATCH`, `STATEMENTS_VERSION_MISMATCH`, `RESULT_STATUS_INCONSISTENT`, `code_issues`, `name_issues`, `version_issue`, `name_check_lines`) вынесены из `services/statement_recompute.py` в `domain/statement_issues.py`; единая функция `core_info_issues(lines, *, form, edition: str | None, order: Collection[str] | None, catalog) -> CoreInfo(drafts, mismatched)` используется приёмом `2.0` и `recompute_form` (поведение пересчёта не изменилось, P3-34). Домен пакет `arm_statements` не импортирует (исключение — `domain/stored_form.py` по D-1): редакцию (`engine.edition`) и перечень кодов (`engine.code_order`) считает сервис; `statement_recompute` реэкспортирует прежние имена. Вместо сигнатуры плана `core_info_issues(*, lines, edition, form_variant, catalog, engine)`. Обоснование: домен не должен знать `StatementsEngine`/`arm_statements` (D-1); одно правило для приёма и пересчёта. Цена ошибки: нет; импортёры прежних имён из `statement_recompute` работают.
294. **P3-40 (B4)** (план 3Б): Приём форм `2.0` — отдельный сервис `services/statement_intake_v2.py` (`map_statement_docs_v2`, `insert_forms_v2`); `map_file` выбирает маппер по типу результата через `_plan_forms` / `_insert_forms` (иначе функция превышала лимит ruff по операторам) и получил необязательный параметр `engine`. `_load_result` и тип в `process_once` остаются под `1.x` до B5. Обоснование: размер `result_mapping.py` и лимиты линтера; граница B4/B5 из брифов. Цена ошибки: нет.
295. **P3-41 (B4)** (план 3Б): `DISCLOSURE` в `2.0` — по-прежнему один документ на файл со статусом `RECOGNIZED` (D-3), `file_ordinal` первого; предупреждения всех объединённых документов результата пишутся в его `document_issue` источником `service`. Статус сервиса и предупреждения файла уровня `error` на него не влияют. Обоснование: спека условие `RECOGNIZED` относится к формам ББ/ОФР; у `DISCLOSURE` нет данных. Цена ошибки: `DISCLOSURE` с `needs_check` сервиса автоподтверждается (данных нет — подтверждать нечего).
296. **P3-42 (B4)** (план 3Б): `STATEMENTS_VERSION_MISMATCH` пишется и при приёме `2.0` (`version_issue(job.statements_version, engine.version)`), а не только при пересчёте после правки. Обоснование: иначе запись появлялась бы только после первой правки; один набор записей `core` у приёма и пересчёта. Цена ошибки: лишний `info` на формах, принятых копией иной версии.
297. **P3-43 (B4)** (план 3Б): Результат с повтором `row_index` или `ref` у строк: вторая и далее строка пропускается с `MAPPING_WARNING`; `detail` с `parent_ref`, которого нет, хранится с `parent_row_index = null` и тем же предупреждением. Обоснование: уникальность `(meta_id, source_ref)` и ключа строки при вставке иначе роняет приём (файл `ERROR` после попыток); схема уникальности `ref`/`row_index` не проверяет. Цена ошибки: потерянная строка дубля видна только в предупреждении.
298. **P3-44 (B4)** (план 3Б): Запись `relation` / `soft` из `checks` результата несёт `column_id` (колонка с таким `column_index`, если есть) и всегда `column_index` (как P3-33); запись `warning` с `column_index` — либо `column_id` (колонка есть), либо `column_index` (колонки нет). Обоснование: спека «Пропущенная колонка» и ответ API, читающий индекс из записи. Цена ошибки: нет.
299. **P3-45 (B4)** (план 3Б): `is_current` колонок `2.0` — как у `1.x` (колонка с самой поздней датой, при равенстве меньший `column_index`; нет дат — `NULL`), в том числе у колонок `relative:*` с датой; значение, которого нет в результате, пишется с `is_recognized = NULL`, остальными полями пустыми и `needs_review = false`. Обоснование: §9.5 п. 2; спека «Хранение результата формы v2.0». Цена ошибки: колонка `relative:*` без даты не станет текущей.
300. **P3-46 (B5)** (план 3Б): `_load_result` выбирает модель по MAJOR через `contract.versions.parse_result`; неизвестный MAJOR, неверный формат версии и любое нарушение схемы — `MappingFatal` с итогом проверки схемы (`результат не соответствует схеме recognition-result: ошибок N, первая: …`), файл `ERROR`, без повтора. Обоснование: спека «Приём файла результата»; D-2. Цена ошибки: файл с MAJOR 3 не мапится, а не обрабатывается молча как `1.x`.
301. **P3-47 (B5, T5.2 части А)** (план 3Б): Нечитаемый JSON (`JSONDecodeError`, не UTF-8) в `_load_result` — тоже `MappingFatal` с текстом «результат не соответствует схеме recognition-result: файл не является JSON» (текст исключения парсера пользователю не показывается; M-1); не-объект (`[]`, `null`) отсекается моделью как нарушение схемы. Обоснование: иначе `ValueError` из `parse_result` уходил бы в повторяемые ошибки и файл `ERROR` получал бы только после `MAPPING_MAX_ATTEMPTS` попыток. Цена ошибки: ошибка JSON не отличается в тексте от прочих ошибок схемы.
302. **P3-48 (B5)** (план 3Б): Статус ОСВ `2.0` — `osv_status_v2(osv_done = sheet.status == "done", checks_ok, file_error_warning, counterparty, sheet_mapping_warnings)`; «предупреждения маппинга листа» = повтор счёта + сироты + «нарастающая по данным Q1» (вход `document_status` `1.x` тот же); у `1.x` статус как прежде (§9.3, статус задания). Предупреждение уровня `error` ищется в `metrics.warnings` результата (`file_warnings`), а не в `recognition_job.metrics`. Обоснование: Р-28; единый источник с формами. Цена ошибки: ОСВ с `error` в метриках файла — `NEEDS_CHECK`.
303. **P3-49 (B5)** (план 3Б): `osvDocument.warnings` пишутся в `document_issue` как `warning` / `service` с `severity` из предупреждения (по контракту `info`, `error` не отбрасывается); `line_refs` у ОСВ не сохраняются (строк формы нет), `column_index` и `field` — как пришли. Записи сервиса идут раньше записей маппинга ядра. Дубль листа (повтор счёта) своих предупреждений/статуса не вносит — документом остаётся первый лист. Обоснование: у ОСВ нет `line_ids`/колонок формы; повтор — D-7. Цена ошибки: `error`-предупреждение листа ОСВ в `issues` не влияет на статус само по себе (статус — по `status` листа).
304. **P3-50 (B6)** (план 3Б): `FailedCheck.codes` в ядре остаётся (вопреки плану, который удалял поле): оно нужно межформенной проверке 1370↔2400 (`codes = ("1370", "2400")`) для `codes` / `line_ids` в `checks.cross_form` ответа; нарушения §7 по колонкам читаются из `document_issue`, где коды хранятся сами. Обоснование: межформенную проверку пакет не считает (она по двум документам), без `codes` подсветка строк в ответе пропала бы. Цена ошибки: у `cross_form` не было бы `codes`/`line_ids`, фронт не подсвечивал бы строки 1370 и 2400.
305. **P3-51 (B6)** (план 3Б): `checks_report` уже брал допуск у `engine.tolerance()` (с B2), поэтому правки сервиса в B6 нет; константа допуска ядра удалена вместе с `load_check_rules`. Состав и порядок редакций справочника имён `statement_lines.yaml` не переписывается на `engine.editions()` в коде: согласованность проверяет тест `test_name_dictionary_codes_in_code_orders` (редакции справочника = редакции пакета в том же порядке; каждый код справочника есть в перечне редакции пакета, любой вариант формы). Обоснование: справочник остаётся данными имён и состава экрана; перечни кодов — единственный источник в пакете, расхождение ловит тест, а не молчаливая подмена в рантайме. Цена ошибки: при добавлении редакции в пакет без справочника имён тест красный; в рантайме новая редакция дала бы форму без шаблонных строк.
306. **P3-52 (B6)** (план 3Б): `edition_for` (граница 66n/fsbu4 по дате для шаблона экрана) в `domain/statement_mapping.py` не тронут: переход на `engine.edition(...)` требует кодов формы и варианта и относится к ответу формы (B7/доработка), а не к удалению копии словаря. Обоснование: не расширять B6 за рамки брифа. Цена ошибки: ответ формы и приём могут расходиться в редакции на формах без даты; существующее поведение сохранено.
307. **P3-53 (fix-волна, I-3, R-11)** (план 3Б): Источник «ошибки файла» единый: при приёме результата `2.0` `map_file` сохраняет `result.metrics.warnings` структурами в `recognition_job.metrics.file_warnings` (рядом с `mapping_warnings`, прочие ключи не затираются, `_mapped_metrics`); `recompute_form` читает его через `file_error_in_metrics(metrics)`, при отсутствии ключа (результат `1.x`, задания до части Б) — прежний `metrics.warnings` конверта. Приём (`insert_forms_v2`, ОСВ) по-прежнему читает результат напрямую. Обоснование: раньше приём смотрел предупреждения файла результата, а пересчёт — конверта: статус формы мог измениться от несвязанной правки. Цена ошибки: при расхождении конверта и результата форма менялась бы `NEEDS_CHECK` ↔ `RECOGNIZED` без причины; задание без ключа читается по-старому.
308. **P3-54 (fix-волна, I-2)** (план 3Б): `build_stored_form` передаёт пакету `is_recognized = value.is_recognized is not False`: `NULL` (заглушки маппера `1.x`/`2.0`, ячейки, созданные правкой) — не сомнение, как в `value_clean` ядра и в спеке пакета («позиции нет → `is_recognized = true`»). Прежнее `bool(...)` превращало `NULL` в `False`, и после любой правки шапки значения без сомнений получали `needs_review`, а форма — `NEEDS_CHECK`. Обоснование: `is_recognized = NULL` означает «признака нет», а не «не распознано». Цена ошибки: форма из заглушек или с созданной ячейкой уходила бы в `NEEDS_CHECK` от правки шапки.
309. **P3-55 (fix-волна, T4.2)** (план 3Б): `checks_on_intake_1x(..., now)` принимает момент приёма параметром (из `map_file`), а не `utc_now()`: записи `relation` / `soft` формы `1.x` получают тот же `created_at`, что и остальные записи приёма. Обоснование: единое «сейчас» приёма, воспроизводимость тестов. Цена ошибки: различие `created_at` на миллисекунды между записями одного приёма; порядок записей в ответе мог бы нарушиться.
310. **P3-56 (fix-волна, M-4)** (план 3Б): `statement_mapping_v2._lines_and_values` строит `ref → row_index` только по принятым строкам (первый вход побеждает; ссылка вперёд на ещё не встреченную принятую строку допустима): `parent_ref` строки, чей `ref` повторяет прежний, указывает на первую принятую строку, а не на пропущенный дубль (раньше побеждал последний). Обоснование: пропущенная строка не должна становиться родителем; детализация висела бы на несохранённом `row_index`. Цена ошибки: расшифровка привязана не к той строке; пересчёт `detail_parents` всё равно исправляет её после первой правки.
311. **P3-57 (fix-волна, T4.4, T6.1)** (план 3Б): Закрыты тестами ветки приёма `2.0`: пропуск дубля `row_index` / `ref`, потерянный `parent_ref`, `STATEMENTS_VERSION_MISMATCH` (и его отсутствие при совпадении версии), `DISCLOSURE` с предупреждением `error` (документ `RECOGNIZED`, запись сохранена; вопрос владельцу P3-41 остаётся открытым), `edition is None` (нет `CODE_NOT_IN_*`, `NAME_MISMATCH` остаётся), предупреждение листа ОСВ `error` не блокирует подтверждение (P3-49). Обоснование: покрытие веток без изменения поведения. Цена ошибки: нет.
312. **P3-58 (fix-волна, I-4)** (план 3Б): Тесты правки и акцепта формы, принятой маппером `2.0` (`tests/test_statements_v2_edit.py`): записи `relation` / `soft` источника `service` после первой правки заменяются записями `core`; `warning` сервиса уровня `error` и `RESULT_STATUS_INCONSISTENT` сохраняются и держат `NEEDS_CHECK`; `RECOGNIZED` не меняется от правки шапки; акцепт нарушения, найденного ядром. Наблюдение: «чистая» форма с фабрики `balance_lines()` по реальному пакету не сходится (1300 без членов) — для тестов пересчёта берётся согласованная форма. Обоснование: тест должен ловить регрессию, а не проходить на форме, у которой пересчёт и так находит нарушение (мутационная проверка I-2 подтверждает). Цена ошибки: тесты на несогласованной форме проходили бы вхолостую.
313. **P3-59 (fix-волна, M-5, M-6, M-7, T6.3)** (план 3Б): Документация: CLAUDE.md репозитория исправлен под P3-30 (пустая цель при заполненных членах — нарушение с `expected = NULL`), «сбрасывает акцепт» — только правка данных (Р-25), добавлено описание приёма результата `2.0`; README — формы, принятые кодом части А, теряют проверку `CHECKS_NOT_PASSED` до первой правки (§7 в `document_issue` у них нет; обход — очистка форм `purge_statements.py`). P3-39 уточнён: домен не импортирует `arm_statements`, исключение — `domain/stored_form.py` (D-1). Обоснование: документация должна совпадать с кодом. Цена ошибки: читатель CLAUDE.md/README получал бы неверные гарантии о проверках и акцепте.
314. **P3-60 (fix-волна, I-5)** (план 3Б): В ответах нарушение §7 может иметь `expected` / `actual` = `null` (пустая цель при непустых членах, P1-21). Индекс колонки при её отсутствии различается: `checks.failed[]` ответа `GET …/statement` отдаёт `-1` (`FailedCheckOut.column_index: int`, межформенная `cross_form` всегда `-1`), а `detail.failures[]` отказа 409 `CHECKS_NOT_PASSED` — `null` (`issue_column_index`). Спека описывает поведение как есть; код не правился. **Вопрос на выравнивание (`-1` или `null`) — план 6 / фронт (arm-web)**. Обоснование: фиксация фактического контракта до решения владельца; тип поля у фронта (arm-web) нужно сверить. Цена ошибки: фронт, ожидающий число, получит `null` в `detail.failures[].column_index` (только в 409, когда у записи нет ни `column_id`, ни `column_index`).
315. **P3-61 (fix-волна, C-1, M-1)** (план 3Б): Дельты B переписаны под `openspec archive`: MODIFIED «Пересчёт после правки», «Словари ядра и достройка шаблонных строк», «Формы результата 1.x — §7 при приёме» теперь несут полный текст требования части А со всеми сценариями плюс уточнения Б (иначе архивация падает: «scenario(s) not present in the modified block»); добавлен MODIFIED «Хранение результата формы v2.0 (DBML v5.19)» и «Форма ББ/ОФР для экрана проверки» (сценарий «Строка, потерянная источником» приведён к P3-30). ADDED «Маппер результата 2.0 для форм» заменён MODIFIED «Хранение…» (устранено противоречие «первое остаётся» vs «без повторной дедупликации») и ADDED «Проверки ядра при приёме результата 2.0»; ADDED «`CHECKS_NOT_PASSED` из `document_issue`» и «Блок `checks`…» оставлены только с тем, чего нет в А (форма элемента нарушения, `null`), дубль порядка 409 убран. Проверено архивацией А, затем Б на копии `openspec/`. **Дополняет R-3 и D-4**. Обоснование: архивация обязана проходить; дубли и противоречия между А и Б вводят в заблуждение. Цена ошибки: если архивация А и Б падает — править заголовки и сценарии дельт Б; при будущей правке требований А в самой А тексты MODIFIED Б нужно обновить синхронно.
#### План 4 (tabular-parser, change `unified-normalization-tp`)
Источник: `openspec/changes/unified-normalization-tp/design.md` в репозитории `tabular-parser`, коммит `b583b04` (ветка исполнения; полный текст — там). Перенесено пунктов: 30.
316. **P4-1** (план 4): Переход на общий код и на контракт v2.0 — одна задача (7): модели, копия схем, сборка книги, путь ОСВ и воркер меняются вместе; «контракт v2.0 в копии» отдельной последней задачей не вынесен — модели 2.0 без копии 2.0 (или наоборот) валят `result_contract` и тест идентичности. Golden и манифест — сразу за переключением (задача 8), до удаления старого кода; между задачами 7 и 8 проверка идёт с `ARM_KA_FIXTURES=/nonexistent`. Цена: одна крупная задача и окно в одну задачу без проверки на реальных файлах.
317. **P4-2** (план 4): Адаптер пишется новыми модулями рядом со старыми (`cells`, `forms`, `header_cells`, `adapter`, `contract`, `pipeline`); старые `statements/*` не правятся и удаляются целиком задачей 9. Цена: временное дублирование логики поиска блоков и шапки.
318. **P4-3** (план 4): `Cell.text` числа Excel — как в русской локали: группы по три через пробел, если в формате есть разделитель групп; десятичная запятая; литералы секции (скобки) сохраняются; «-» добавляется только у односекционного формата; `General`/неразобранный формат/xlsb — число без групп и экспоненты, целое без дробной части; дата — `dd.mm.yyyy`; логическое — «ИСТИНА»/«ЛОЖЬ». Цена: у экзотических форматов текст может отличаться от экрана Excel; `value` и статус от этого не зависят (у Excel значение берётся из `value`). Совпадает с примером docs `result-statement-excel.json` (`"586 260,26"`).
319. **P4-4** (план 4): Показываемое число вне `numeric(18,2)` больше не делает формат «неразобранным»: адаптер отдаёт `Decimal`, а `VALUE_OUT_OF_RANGE` и `amount = null` выдаёт пакет (спека «Шаг 4»). Цена: правка поведения #42 для чисел больше 16 целых цифр (на реальных файлах их нет).
320. **P4-5** (план 4): Сетка кандидата: строки блока целиком, включая пустые; объединённая ячейка — только в позиции левой верхней, прочие позиции `None`; ширина — наибольшая среди листов кандидата. Цена: подпись, объединённая на два столбца, видна пакету только в левом.
321. **P4-6** (план 4): Шапка ищется в строках блока выше первой строки с кодом его столбца кодов (столбец — `code_column` пакета: то же правило (а)/(б) шага 2 с защитой от блока «Коды», своего правила у адаптера нет), без кодов — во всём блоке: так титульный блок «Коды» (ОКОПФ 12267) не мешает, а подписи под формой (ИНН и ФИО в блоке подписей) не попадают в шапку. Цена: метка шапки ниже начала таблицы не найдётся.
322. **P4-7** (план 4): Шаблоны словаря пакета адаптер применяет к тексту, нормализованному функцией пакета по таблице «Словари» раздела API плана 1: метки шапки, заголовки, ОКУД, `auditor`, `legal_form` — `normalize_label` (= прежний `normalize_text` tabular-parser, P1-22); подпись «Код» и относительные периоды — `normalize_caption`; маркер продолжения — `is_continuation`. Цена: нет — смена нормализации в пакете меняет и `type_hint`, и адаптер.
323. **P4-8** (план 4): Склейка: открытая форма — последний кандидат с `type_hint` `BALANCE`/`INCOME`; лист с маркером сверяется только по типу; лист без маркера (сильное продолжение — столбец кодов по правилу шага 2 или своя строка заголовка; слабое — столбец кодов ослабленным поиском `code_column(…, relaxed=<тип формы>)`, минимум `min_codes_continuation` кодов, сразу после листа формы) — `sheet_joins` с числом колонок сумм `len(amount_columns(...))` пакета («столбцы правее столбца кодов, где в строках с кодами есть число»); открытой формы нет — отдельный кандидат без предупреждения (пакет даст `NO_ORGANIZATION_NO_INN`). Цена: «Изменение, %» с числами считается колонкой сумм с обеих сторон сравнения; лист с иной шапкой, но тем же числом колонок, присоединится и будет сопоставлен пакетом по датам.
324. **P4-9** (план 4): `SHEET_OTHER` для листов «прочее» выдаёт книга сама (документа и кандидата нет), а не пакет по кандидату без `type_hint`. Цена: два места порождения одного кода (у пакета — для кандидатов, не нашедших ни типа, ни кодов).
325. **P4-10** (план 4): `XLSB_NO_FORMATS` — у каждого кандидата книги xlsb (P1-5); `NUMBER_FORMAT_UNPARSED` — одно на кандидата с перечнем форматов всех его листов. Цена: у пояснений xlsb тоже есть `XLSB_NO_FORMATS` (`info`).
326. **P4-11** (план 4): Книга вызывает `normalize_candidate` по одному кандидату, а не `normalize`: так проверяется дедлайн между кандидатами (`header.form_name` пакет ставит сам из `evidence.title`, пара «кандидат → документ» не нужна). Исключение пакета на кандидате — `RECOGNITION_FAILED` всей книги, как сейчас (`TABLE_PARSE_ERROR` — код таблицы ИС). Цена: одна сломанная форма роняет книгу с ОСВ.
327. **P4-12** (план 4): Коды ОСВ — по источнику предупреждения, не по тексту: `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` разные случаи различаются только текстом.
328. **P4-13** (план 4): Заметки читателя: несовпадение формата — `FILE_TYPE_MISMATCH` файла; «xls без кодовой страницы» — `XLS_NO_CODEPAGE` файла (`info`, код пакета, P1-23) в любой книге xls — касается и форм, и ОСВ; «xlsb без outline» — в каждый документ ОСВ книги (`OSV_XLSB_NO_OUTLINE`), в книге без ОСВ только логируется (иерархия строк — только у ОСВ). Цена: предупреждение о кодировке видно в карточке файла, а не у листа ОСВ.
329. **P4-14** (план 4): Предупреждение «форма не разобрана: книга завершилась ошибкой листа ОСВ» снято: в v2.0 для него нет кода, а текст ошибки и так называет лист ОСВ. Цена: аналитик не видит списка листов форм, оставшихся неразобранными.
330. **P4-15** (план 4): Статус документа ОСВ — прежние условия листа: контрольные суммы §1.4 сошлись и строки ИНН без нарушений (`glue.ok`, #35). Цена: формулировка спеки «когда контрольные суммы сошлись» читается шире.
331. **P4-16** (план 4): Нет `api/arm_statements/VERSION` — сервис не стартует (`RuntimeError`), как при ошибке словаря; в golden `service.statements_version` — volatile-поле. Цена: копия без `VERSION` не запустится даже локально.
332. **P4-17** (план 4): Новые синтетические случаи (форма xlsb, книга из пояснений, конфликты продолжений) — в памяти в юнит-тестах (`sheet_from_rows`, `write_xlsb`), новых файлов в `fixtures/statements-xlsx/synthetic` нет; PR fixtures — только golden.
333. **P4-18** (план 4): Манифест получает необязательное поле `error_warnings` (точное множество кодов `error` документа); проверка уникальности кодов строк снята (повторы законны, Р-4). Цена: при правке пакета, меняющей набор предупреждений, манифест правится вместе с golden.
334. **P4-19** (план 4): Тест покрытия словарей tabular-parser больше не охватывает словари форм — они в пакете. Цена: «мёртвый» синоним заголовка или метки шапки на реальных файлах не ловится (O-8).
335. **P4-20** (план 4): В tabular-parser — свой change `unified-normalization-tp` с дельтами `statement-parsing`, `tabular-recognition-service`, `osv-parsing` (по образцу P0-5); требование «Копия контракта идентична источнику» правит план 0, здесь не трогается.
336. **P4-21** (план 4): Golden — отдельные issue, ветка и PR в RSHB/arm-ka-fixtures (один PR на репозиторий); сливается сразу за PR tabular-parser. Цена: короткое окно, когда `make check` `main` одного из них красный на golden.
337. **P4-22** (план 4): Две формы на одном листе дают два документа с одинаковым `locator` (спека уникальности `locator` не требует; `ref` строк различаются).
338. **R-2** (план 4): Предупреждение tp #47 «xls без кодовой страницы…» — `XLS_NO_CODEPAGE` пакета (info, файл, без locator); нового `OSV_*`-кода нет; текст сообщения reader'а tp #47 сохранён; передаётся флагом `BookRead.no_codepage`. Цена: правка `book.py` и golden.
339. **R-5** (план 4): Проверка диапазона numeric(18,2) в `display_amount` не возвращается (P4-4); диапазон и `VALUE_OUT_OF_RANGE` выдаёт пакет; старый конвейер удалён задачей 9. Цена: нет после удаления.
340. **R-6** (план 4): Для |raw| ≥ 1e15 текст/значение числа без округления до 15 значащих цифр (иначе число из 17 цифр теряет цифры); пробел групп — обычный U+0020 как в примере docs `result-statement-excel.json`. Цена: расхождение с NBSP русского Excel, на value/статус не влияет.
341. **R-7** (план 4): Ячейки из одних цифр не считаются подзаголовком даты `report_date` (иначе год из тройки «число|месяц|год» принимается за подзаголовок), как в прежнем `header.py`. Цена: подзаголовок из голого числа «2025» не распознаётся.
342. **R-8** (план 4): Ячейка в строке-шапке таблицы (в строке есть подпись «Код» по словарю пакета `code_caption`) не заголовок формы: подпись колонки «Пояснения» (`'^пояснени'` в `form_titles` пакета) делала лист-продолжение НПЗ ещё одной формой DISCLOSURE. Дефект адаптера (спека называет «Пояснения» подписью колонки), пакет не правится. Решение владельца: оставить, посмотреть позже. Цена: компактная шапка, где заголовок формы и «Код» в одной строке, потеряет заголовок.
343. **R-9** (план 4): Предупреждения адаптера при кандидате без документа — предупреждения файла со своим `locator`; порядок `metrics.warnings` — сначала reader, затем по номеру первого листа; `XLS_NO_CODEPAGE` и `OSV_XLSB_NO_OUTLINE` — флаги `BookRead` (`no_codepage`, `no_outline`). Цена: правка `book.py` и golden.
344. **R-10** (план 4): НПЗ, ОФР, строки «Реализация иного имущества, прав требования, прочих работ и услуг» (F34, F44): значения 0 с форматом `[=0]"-"` — только прочерки, суммы нет; по спеке «строка без кода и без содержимого — не строка данных», пакет её не выдаёт; значение прочерка не превращаем в 0 (сломало бы различие null/0 и соотношения §7). `test_npz_documents` не требует detail/parent_ref 2340. Вопрос владельцу O-8.2: сценарий спеки «Реализация иного имущества… с суммами» не соответствует файлу. Цена: строки из прочерков не видны аналитику.
345. **R-13** (план 4): Ложная склейка/дедлайн: `plan_sheets` получает `deadline` (финальное ревью I-1, регрессия таймаута против main). Цена: нет.
#### План 5 (scan-gateway, change `statements-v2`)
Источник: `openspec/changes/statements-v2/design.md` в репозитории `scan-gateway`, коммит `c5e4d0f` (ветка исполнения; полный текст — там). Перенесено пунктов: 27.
346. **P5-1** (план 5): Сетка OCR — одна строка сетки на строку ИС по возрастанию `row_index`, пропуски `row_index` пустыми строками не заполняются; ширина строк таблицы — `max(column_index) + 1`. «Плотная по `row_index`» прочитано как «по порядку `row_index`», `ref` всё равно несёт номер ИС. Цена: шаг 2 не видит «дыры» между строками (в реальных json их нет).
347. **P5-2** (план 5): Нарушенная структура таблицы ИС (повтор `row_index`, повтор `column_index` в строке, нецелые индексы, `rows` не список) — исключение адаптера → `TABLE_PARSE_ERROR` всей таблицы. Сегодня такие ячейки молча пропускались или перезаписывались; повтор `row_index` сломал бы уникальность `ref`. Цена: одна битая ячейка теряет таблицу (видно — `error`).
348. **P5-3** (план 5): `value` ячейки: `None` → `""`, не строка → `str(value)`, без `strip`; `confidence`: число (не `bool`) → `[0, 100]`, иначе `0` (как `clamp_confidence`, §11.1 «NaN → 0»). Цена: числовой `value` ИС (не встречался) даст текст `"1234.0"`.
349. **P5-4** (план 5): Ячейка ИС с пустым текстом — `Cell("", …)`, а не `None`: ИС отдаёт ячейку на каждую позицию строки, `None` — только там, где ячейки нет. Цена: нет.
350. **P5-5** (план 5): Шапка: поле ИС отсутствует, не объект или `form_name` пустой → `None` (а не `Cell("", 0)`); `ref` полей шапки — `data_ocr.<поле ИС>` (пакет `ref` шапки в выход не переносит — у `HeaderField` его нет, план 1, раздел API); `data_ocr.label` (заголовок комплекта) не используется. Цена: `<поле>_text` пустого поля — `null`, а не `""`.
351. **P5-6** (план 5): `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` сам не ставит и пару «кандидат → документ» не хранит.
352. **P5-7** (план 5): Успешный json без `data_ocr`, без `table_structure` или с пустым `table_structure` — `JSON_FAILED` (`error`): «ничего не выбрасывать молча» (§11, принцип 2). Цена: если ИС вернёт успешный пустой json рядом с нормальными, конверт будет `needs_check`. Q-1 закрыт решением владельца Р-32 (подтверждает выбор плана).
353. **P5-8** (план 5): Все json набора ошибочны — конверт `error` с кодом первого, а `metrics.warnings` всё равно перечисляют `JSON_FAILED` каждого json (как строки Среза 0): иначе коды остальных json теряются. Цена: спека называет `JSON_FAILED` «при успешном соседе»; на `error`-конверт ядро `RECOGNIZED` не считает, вреда нет. Q-2 закрыт решением владельца Р-33 (подтверждает выбор плана).
354. **P5-9** (план 5): `TABLE_PARSE_ERROR.message` = `BACKEND_PARSING_ERROR: <тип>: <текст>` (текст до 300 символов) — код старого §11.1 п. 9 сохранён в тексте для поиска по логам и golden. Цена: нет.
355. **P5-10** (план 5): Предел `metrics.warnings` (100 структур, `message` до 1000 символов) сохранён; при переполнении остаются все `error`, затем `info`; строки-сводки нет — новый код предупреждения вводится только словарём пакета. Список в конверте и в файле результата одинаков (спека). Цена: при > 100 предупреждениях часть `info` не видна ни там, ни там (сырой ответ — в `raw/`).
356. **P5-11** (план 5): `statements_version` читается при создании `OcrNormalizer`; нет `VERSION` — сервис не стартует. Версия едет в `NormalizeOutcome.statements_version` и в `service.statements_version` файла результата; в конверт не пишется (в схеме конверта поля нет). Цена: dev-checkout без `VERSION` не стартует (копию всегда раскладывает скрипт).
357. **P5-12** (план 5): `api/rules/` удалён целиком, `SCAN_RULES_DIR` убран из настроек; словари — `default_rules()` пакета. Цена: переопределить словарь без релиза нельзя (и не нужно — словари меняются через `arm-statements`).
358. **P5-13** (план 5): `CONTRACT_VERSION = "2.0"` — для файла результата и всех конвертов, включая ack; MAJOR запроса — отдельная константа (`SUPPORTED_MAJOR = 1`).
359. **P5-14** (план 5): Golden v2 — `{schema_version, status, error, metrics, documents}` файла результата без `job_id`, `file_id`, `service`, `raw_s3_key` (`statements_version` меняется при каждой раскладке копии); успешный golden дополнительно проходит схему v2.0. Цена: golden не ловит регресс поля `service`.
360. **P5-15** (план 5): Перевод в контракт — `to_contract` / `warning_to_contract` пакета (план 1, P1-26): у предупреждений документа нет `locator` и пустых необязательных ключей (схема не допускает `null` у `column_index` и `field`), у нарушений §7 нет `column_key`, у предупреждений файла `locator` есть; шлюз только валидирует `dict` своими моделями. Цена: правка правил перевода — в `arm-statements`, а не здесь.
361. **P5-16** (план 5): Модули: `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`) нет.
362. **P5-17** (план 5): Review Focus 3 утверждает `done` для обоих документов «БО 1 кв 2025» и «Бухгалтерская отчетность 1 кв.25» (proposal, Р-23). Падение при верном кандидате — находка для пакета, а не правка шлюза; ослабить утверждение может только контроллер рулингом.
363. **P5-18** (план 5): `metrics.rows` — число строк `lines` всех документов (как в v1.2), теперь включая строки без кода.
364. **P5-19** (план 5): `NullNormalizer` сохраняется для тестов воркера и выдаёт `JSON_FAILED` через `make_warning` пакета.
365. **P5-20** (план 5): Обход набора зовёт `normalize_candidate` на каждую таблицу, а не `normalize`: исключение одной таблицы — `TABLE_PARSE_ERROR` с `locator` таблицы, остальные таблицы разбираются (план 1: `normalize` исключения кандидата не перехватывает). `AnswerSetResult.documents` — документы пакета как есть (заголовок — в `header.form_name`).
366. **R-1** (план 5): Коммиты ветки несут трейлеры `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>` (бриф владельца) и `Claude-Session: …` (план) — совпадает с обоими источниками. Цена ошибки: нет.
367. **R-2 (Р-36)** (план 5): Задача 4 копирует из `docs/contracts/examples` только существующие файлы верхнего уровня как есть, новых примеров контракта верхнего уровня не добавляет (решение владельца Р-36). Цена ошибки: нет.
368. **R-3** (план 5): Усечение до 300 символов (P5-9) применяется к тексту исключения, а не ко всей строке `Тип: текст`. Цена ошибки: сообщение чуть длиннее 300 символов при длинном имени типа.
369. **R-4 (T4)** (план 5): `NormalizeOutcome` вынесен в `src/normalizer/outcome.py` с реэкспортом из `src.normalizer` — иначе цикл импортов. Цена ошибки: лишний модуль; импорт из `src.normalizer` работает как раньше.
370. **R-5 (T4)** (план 5): `app.py` вызывает `OcrNormalizer()` (правила `default_rules()` внутри), а не `OcrNormalizer(default_rules())`: импорт `arm_statements` разрешён только в `src/normalizer/**` и `tests/**`. Цена ошибки: нет, эквивалентно.
371. **R-6 (T4)** (план 5): Сравнение с эталонами в `test_golden` временно стояло под `pytest.skip` до задачи 6 (эталоны fixtures были старого формата); в задаче 6 skip снят. Цена ошибки: окно без golden-проверки на ветке между задачами 4 и 6.
372. **R-7** (план 5): Набор «ошибочный json + успешный json без таблиц / без документов» даёт `RECOGNITION_FAILED` / «ни одной таблицы не разобрано», а код первого ошибочного — только если успешных json нет (чтение Р-32 + Р-33; исправление T3.I-1, commit 3b9e1f6). Цена ошибки: при другом чтении код ошибки конверта в смешанном случае будет другим; ядро на `error`-конверт `RECOGNIZED` не считает.
#### План 6 (arm-web, change `statements-v2`)
Источник: `openspec/changes/statements-v2/design.md` в репозитории `arm-web`, коммит `b4b8c04` (ветка исполнения; полный текст — там). Перенесено пунктов: 73.
373. **P6-1** (план 6): Подсказка кода — `StatementOut.edition_lines` (справочник строк редакции формы, у формы без даты — всех редакций; план 3 P3-22), затем коды строк `ok` файла, которых в справочнике нет (имя — `reference_name` ?? `line_name`); отдельного запроса нет. Обоснование: справочник приходит с ответом формы. Цена ошибки: нет.
374. **P6-2** (план 6): Якорь строки `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 (тот же порядок вложений в ответе ядра). Цена ошибки: нет.
375. **P6-3** (план 6): Скрытие (пустые подстроки, расшифровки) применяется **после** раскладки, фильтром. Обоснование: иначе скрытый якорь сдвинул бы вложения к соседу. Цена ошибки: нет.
376. **P6-4** (план 6): Справочное имя (`reference_name`) — основное; файловое (`line_name`, после срезания приклеенных заголовков разделов) показывается вторым серым текстом, только если отличается после нормализации: нижний регистр, `ё` → `е`, оставлены только буквы и цифры. Обоснование: переносы OCR («задолжен-ность») не должны давать ложного «отличается». Цена ошибки: лишнее или пропущенное второе имя — только показ.
377. **P6-5** (план 6): Маркеры «у места» строятся из **всех** записей `issues` (в том числе `relation` / `soft` — у них `line_ids` + `column_id`); список `IssuesPanel` показывает только `kind = warning`; нарушения §7 остаются списком в `ChecksPanel` (из `checks`, с акцептом) и получают ссылку «Показать» по `line_ids[0]`. Запись с `column_id` без `line_ids` (например, `COLUMN_WITHOUT_DATE`) показывается у заголовка колонки; поле шапки, которого нет в блоке шапки, и строки, которых нет в ответе, — у документа. Обоснование: одна запись не дублируется в двух списках; заголовок колонки — естественное место для предупреждения о дате колонки. Цена ошибки: нет.
378. **P6-6** (план 6): Подтверждение ячейки — в черновике; доступно, только если у строки есть `id` и у значения есть `id` (у достроенных ячеек — нет, ядро ответит `UNKNOWN_CELL`); сумма в черновике той же ячейки отменяет её подтверждение. Обоснование: `PATCH`: в элементе `values` ровно одно из `amount` / `confirmed`; правка суммы и так снимает `needs_review`. Цена ошибки: нет.
379. **P6-7** (план 6): Одно действие на строку в черновике (`code` / `detail` / `confirm`), новое заменяет прежнее; пустое поле кода и код, равный текущему, — не действие; у достроенных шаблонных строк (`id = null`) действий строки нет. Обоснование: 422 `INVALID_LINE_PATCH` при двух действиях; `lines[]` адресуется только `line_id`. Цена ошибки: нет.
380. **P6-8** (план 6): Сумма в шаблонную строку по `line_code` X и назначение кода X другой строке в одном сохранении — ошибка черновика «Код X назначается строке, а в шаблонную строку X введены суммы — сохраните по очереди», запрос не уходит. Обоснование: ядро ответит 422 `UNKNOWN_CELL` (P3-10), а сообщение ядра не говорит, что делать. Цена ошибки: нет.
381. **P6-9** (план 6): Ошибка элемента `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). Цена ошибки: нет.
382. **P6-10** (план 6): Строка «неразобранная» для кнопки «Подтвердить данные» — `line_status ∈ {unreadable, missing}` и хотя бы у одного значения `amount !== null` или `string_value !== null` (ровно условие ядра для 409 `LINES_UNRESOLVED`). Обоснование: кнопка и сервер считают одинаково. Цена ошибки: нет.
383. **P6-11** (план 6): Подтверждение даты отчёта — кнопка «Подтвердить дату» у поля из `needs_review_fields` (уходит `header.report_date` тем же значением); дата колонки — поле `ГГГГ-ММ-ДД` у заголовка колонки (как поле даты отчёта) с кнопкой «Подтвердить дату», если дата есть и у колонки `COLUMN_DATE_UNCERTAIN` / `COLUMN_WITHOUT_DATE`. Обоснование: перепечатывать ту же дату ради подтверждения неочевидно; формат ввода — как у существующего поля даты. Цена ошибки: нет.
384. **P6-12** (план 6): Панель файлов: у строки документа — значок с числом «предупреждения файла (`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) помечает документ без запроса на каждый; подробности — по клику. Цена ошибки: акцептованное нарушение тоже даёт значок — подробности в списке.
385. **P6-13** (план 6): `job.warnings` на клиенте нормализуется так же, как ядро: строка → `{code: "LEGACY_WARNING", severity: "info", message, locator: null}`, объект с `code`, `severity ∈ {error, info}`, `message` — как есть, прочее пропускается, не список → `[]`. Обоснование: экран не падает при ошибке порядка выкладки. Цена ошибки: нет.
386. **P6-14** (план 6): Сценарий T11 — на локальном стенде; строки `unreadable` / `missing` / `detail` и записи `issues` всех видов подставляются перехватом `GET` и `PATCH …/statement` (`page.route`) из фикстуры плана, пока сервисы шлют 1.x; на реальных формах 1.x — регрессия. Обоснование: до шага 5 выкладки 2.0-данных на стенде нет. Цена ошибки: повторный прогон на `aep_3` после шага 5 — follow-up.
387. **P6-15** (план 6): Цвета: ячейка `needs_review` — `#fff7e6`, исправлено — `#e6f4ff`, строка `unreadable` / `missing` целиком — `#fff1f0`, строка `ok` с `needs_review` — фон ячеек наименования и кода `#fff7e6`; подтверждённая ячейка — маленькая серая галочка перед суммой; уровень `error` — красный значок `ExclamationCircleOutlined` `#cf1322`, `info` — синий `InfoCircleOutlined` `#1677ff`. Обоснование: палитра уже используется на экране и в панели файлов. Цена ошибки: только вид.
388. **P6-16** (план 6): Ячейка кода: `ok` — код; `unreadable` — серым `line_code_text` (пусто — «—»), подсказка «Код не распознан: «<текст>»»; `missing` — серым «—», подсказка «Кода в строке нет»; `detail` — пусто, подсказка «Расшифровка — в суммы формы не входит»; у `ok` в подсказке «Уверенность кода: N%» (если меньше 100 и код не правлен), «Код подтверждён», «Код назначен аналитиком». Обоснование: спека: «в ячейке кода серым — сырой текст с подсказкой». Цена ошибки: только вид.
389. **P6-17** (план 6): `header.source.<поле>.confidence` — `string | null` (строка Decimal, как прочие уверенности API, план 3 P3-20), в подсказке — `Number()` и округление до целого. Обоснование: одно правило для всех уверенностей. Цена ошибки: нет.
390. **P6-18** (план 6): `node_modules` worktree — симлинк на установленный каталог с eslint, prettier и vitest 3.1.4; поэтому вместо `make check` (он начинается с `npm install` и поставил бы пакеты в чужой каталог) гоняется `make lint typecheck test build` — те же проверки без установки. Обоснование: память владельца: симлинк, как в прежних срезах. Цена ошибки: нет; в описании PR сказано, что install пропущен.
391. **P6-19** (план 6): Предупреждения ОСВ-документа (`DocumentOut.issues`, `OSV_*`) — блок `IssuesPanel` над таблицей экрана ОСВ, все записи — у документа (строки ОСВ не адресуются `line_ids`). Обоснование: спека: «предупреждения листа ОСВ … из `DocumentOut.issues[]`»; экран документа уже загружает `DocumentOut`. Цена ошибки: нет.
392. **R-1** (план 6): Шаги «сверка с макетом / прогон в браузере» (T3 s5, T4 s4, T7–T10) перенесены в T11 (дата 2026-10-18). Обоснование: нет стенда ядра с v5.19, общий Playwright-браузер делят параллельные дорожки. Цена ошибки: визуальные дефекты найдутся позже, в T11.
393. **R-2** (план 6): Ссылка «Показать» также у проверок со статусом `soft_failed` (дата 2026-10-18). Обоснование: согласованное расширение: нарушение так же адресуется строкой. Цена ошибки: только показ.
394. **R-3** (план 6): Компонент меню строки назван `LineMenuButton.tsx`, а не `LineMenu.tsx` (дата 2026-10-18). Обоснование: на регистронезависимой ФС (macOS) `LineMenu.tsx` и `lineMenu.ts` дают TS1149. Цена ошибки: нет.
395. **O-1** (план 6): Два MODIFIED-требования («Форма ББ/ОФР — просмотр и правка», «Подтверждение документа») составлены исполнителем из текущей спеки: в docs-спеке `arm-web` все требования ADDED. «Предупреждения ОСВ-документа» (P6-19) в docs-спеке нет; написано по плану. Сверить в arm-ka-docs#116 (дата 2026-10-18). Обоснование: иначе change нельзя было бы описать дельтой. Цена ошибки: расхождение формулировок со спекой docs при архивации.
396. **T1.D-1** (план 6): `cellFlags` возвращает ещё и `confirmed`; двум старым ожиданиям добавлен `confirmed: false`; тест «внимание по уверенности» заменён на «внимание только по needs_review» (дата 2026-10-18). Обоснование: `toEqual` падает на новом ключе; так велит бриф. Цена ошибки: только тесты.
397. **T1.D-2** (план 6): В тестовый хелпер `doc()` добавлен `issue_counts: {error: 0, info: 0}` (дата 2026-10-18). Обоснование: поле обязательно в `DocumentBrief`, иначе падает tsc. Цена ошибки: нет.
398. **T1.D-3** (план 6): Значения фикстуры `statement-v2.json`, которых нет в брифе, придуманы исполнителем (форматы сумм, уверенности, `text`, UUID, `codes`, сообщения, `is_current`, пустая колонка c2, порядок `edition_lines`) (дата 2026-10-18). Обоснование: фикстура должна быть полной. Цена ошибки: тесты завязаны на выдуманные значения; реальные данные — в T11.
399. **T1.D-4** (план 6): Фикстура приведена к `StatementOut` через `as` в тесте (дата 2026-10-18). Обоснование: импорт JSON расширяет литералы до `string`; полноту проверяет рантайм-тест пар «строка × колонка». Цена ошибки: слабая проверка типов фикстуры.
400. **T1.D-5** (план 6): Тест «переносит поля v2» проверяет ключи по `l-u7`, `code-1250`, `l-1230`, `l-d2` (дата 2026-10-18). Обоснование: ровно как в брифе. Цена ошибки: нет.
401. **T3.D-1** (план 6): Несколько строк подсказки кода склеиваются переводом строки; у `codeModified` уверенность не показывается (дата 2026-10-18). Обоснование: читаемость подсказки. Цена ошибки: только вид.
402. **T3.D-2** (план 6): Подсказка сумм принимает необязательный `issues` (добавляет message) (дата 2026-10-18). Обоснование: задел для T4. Цена ошибки: нет.
403. **T3.D-3** (план 6): Уверенность округляется `Math.round(Number())` (P6-17) (дата 2026-10-18). Обоснование: одно правило для всех уверенностей. Цена ошибки: нет.
404. **T3.D-4** (план 6): В режиме правки у подтверждённой ячейки галочка — `prefix` поля ввода (дата 2026-10-18). Обоснование: ячейка в режиме правки — Input. Цена ошибки: наложение значков, см. T8.D-3.
405. **T3.D-5** (план 6): `id` строки `row-<key>` выставляется только на строках-линиях, не на заголовках разделов (дата 2026-10-18). Обоснование: якорь прокрутки нужен только строкам. Цена ошибки: нет.
406. **T4.D-1** (план 6): Подписи мест полей шапки: organization_name — «Организация», inn — «ИНН», report_date — «Дата отчёта», units — «Единица измерения»; неизвестное поле — своим кодом, запись уходит в `document` (дата 2026-10-18). Обоснование: P6-5. Цена ошибки: только вид.
407. **T4.D-2** (план 6): Запись с `field` из блока шапки всегда в `byField`, независимо от `line_ids` (дата 2026-10-18). Обоснование: поле шапки — самостоятельное место. Цена ошибки: нет.
408. **T4.D-3** (план 6): `warning` с `line_ids` без `column_id` — место «строка …», с `column_id` — «<строка>, колонка «…»»; `relation` / `soft` в список не входят (дата 2026-10-18). Обоснование: P6-5. Цена ошибки: только вид.
409. **T4.D-5** (план 6): `onShow` панелей передаёт ключ строки; для достроенных строк (`id = null`) перехода нет — `line_ids` их не содержат (дата 2026-10-18). Обоснование: нет id — нет адреса. Цена ошибки: нет ссылки «Показать» у достроенной строки.
410. **T4.D-6** (план 6): Значок у наименования строки — только по `byRow`; записи `byCell` дают значок только в ячейке (дата 2026-10-18). Обоснование: не дублировать. Цена ошибки: только вид.
411. **T5.D-1** (план 6): Кнопка «Подтвердить дату» рисуется только при переданном `onConfirmDate` (дата 2026-10-18). Обоснование: иначе мёртвая кнопка. Цена ошибки: нет.
412. **T5.D-2** (план 6): У организации в подсказке при modified — «Распознано: «…»»; нет `source` — подсказки нет (дата 2026-10-18). Обоснование: нечего показывать. Цена ошибки: нет.
413. **T5.D-3** (план 6): Уверенность в подсказке шапки через `Number()` ('67.00' → 67%) (дата 2026-10-18). Обоснование: P6-17. Цена ошибки: нет.
414. **T5.D-4** (план 6): Placeholder организации — «Наименование организации» (текст не из плана) (дата 2026-10-18). Обоснование: поле в режиме правки должно быть подписано. Цена ошибки: только вид.
415. **T6.D-1** (план 6): `buildStatementPatch` для суммы в неизвестную строку возвращает ошибку `amount` (раньше бросалось исключение) (дата 2026-10-18). Обоснование: экран не должен падать. Цена ошибки: нет.
416. **T6.D-2** (план 6): Подтверждение ячейки и действие строки у строк без `id` молча пропускаются (дата 2026-10-18). Обоснование: UI их не создаёт (P6-6, P6-7). Цена ошибки: молчаливая потеря действия при ошибке UI.
417. **T6.D-3** (план 6): Код сравнивается после `trim`; пустой или равный текущему — не действие и не ошибка, проверка до формата (дата 2026-10-18). Обоснование: P6-7. Цена ошибки: нет.
418. **T6.D-4** (план 6): Конфликт P6-8 ищется по сумме в шаблонную строку (`id = null`) с тем же кодом; `key` ошибки — `lineId` строки с действием (дата 2026-10-18). Обоснование: P6-8. Цена ошибки: нет.
419. **T6.D-5** (план 6): Тексты ошибок: «Наименование организации — не длиннее 1000 символов» (длина организации); «Исправьте суммы с неверным форматом» (суммы, как раньше) (дата 2026-10-18). Обоснование: тексты не из плана. Цена ошибки: только вид.
420. **T6.D-6** (план 6): `confirm_report_date` при отсутствии текущей даты ничего не отправляет (дата 2026-10-18). Обоснование: нечего подтверждать. Цена ошибки: пустое тело PATCH, «Сохранить» молчит.
421. **T6.D-7** (план 6): Ошибка `amount` подсвечивает ячейку красной рамкой и сама по себе (дата 2026-10-18). Обоснование: раньше подсветка шла только по разбору формата. Цена ошибки: только вид.
422. **T7.D-2** (план 6): Подпись пункта меню: «Это расшифровка» для `unreadable` / `missing`, «Сделать расшифровкой» для `ok` (дата 2026-10-18). Обоснование: разные смыслы действия. Цена ошибки: только вид.
423. **T7.D-3** (план 6): Меню строки у `ok` — только при `origin = 'source'`; шаблонные строки с id меню не получают (дата 2026-10-18). Обоснование: бриф. Цена ошибки: нет.
424. **T7.D-4** (план 6): `lineErrorTarget` читает `ApiError.body.detail` (отдельного поля `detail` у `ApiError` нет) (дата 2026-10-18). Обоснование: иначе деталей ошибки не достать. Цена ошибки: нет.
425. **T7.D-5** (план 6): Ошибка сервера сбрасывается любой правкой черновика (в `setDraft`) (дата 2026-10-18). Обоснование: иначе устаревшая ошибка висит после правки. Цена ошибки: нет.
426. **T7.D-6** (план 6): У `unreadable` / `missing` с действием detail / confirm поле кода остаётся, рядом тег (дата 2026-10-18). Обоснование: бриф: поле всегда. Цена ошибки: см. T7.2: очистка поля теряет действие.
427. **T8.D-1** (план 6): Подтверждение ячейки без `id` значения молча пропускается в `buildStatementPatch`; кнопка не рисуется (`cellConfirmState → null`) (дата 2026-10-18). Обоснование: P6-6. Цена ошибки: нет.
428. **T8.D-2** (план 6): Ожидающая кнопка даты — disabled с текстом «Дата подтверждена (до сохранения)»; без ожидания «Подтвердить дату»; отменить подтверждение даты можно только отменой всей правки. Для колонки ожидание = черновая дата равна `period_date`. Затем текст подсказки при нажатой кнопке подтверждения ячейки: «Подтверждение будет сохранено — нажмите, чтобы убрать» (дата 2026-10-18). Обоснование: пользователь должен видеть, что подтверждение в черновике. Цена ошибки: текст не из плана; ширина колонки (T8.2) проверяется в T11.
429. **T8.D-3** (план 6): При значке записи у Input `paddingLeft: 14`, чтобы значок не перекрывал галочку (дата 2026-10-18). Обоснование: без браузера не проверено. Цена ошибки: визуальное наложение; проверка в T11.
430. **T8.D-4** (план 6): `cellConfirmState` возвращает `pending-unconfirm` при `confirms[key] === false` (дата 2026-10-18). Обоснование: нужно третье состояние. Цена ошибки: нет.
431. **T8.D-5** (план 6): Подтверждение ячейки только строкам с `lineId` (дата 2026-10-18). Обоснование: P6-6, P6-7. Цена ошибки: нет.
432. **T9.D-1** (план 6): «Значения есть» = `amount !== null || string_value !== null` по любой ячейке; `detail` не блокирует независимо от значений (дата 2026-10-18). Обоснование: P6-10. Цена ошибки: расхождение с условием ядра.
433. **T9.D-2** (план 6): Ключ ссылки в 409 `LINES_UNRESOLVED` — `line_id` (дата 2026-10-18). Обоснование: ключ GridRow для строк из БД. Цена ошибки: ссылка молчит, если строки нет в таблице (T9.6).
434. **T9.D-3** (план 6): Описание Alert подтверждения собирается императивно (`confirmDescription`); приоритет: failures > lines > duplicate (дата 2026-10-18). Обоснование: линтер запрещает вложенные тернарники. Цена ошибки: только вид.
435. **T10.D-1** (план 6): `toFileWarnings` отбрасывает объекты без `code` / `message` или с `severity` не `error` / `info` (дата 2026-10-18). Обоснование: P6-13. Цена ошибки: нет.
436. **T10.D-2** (план 6): В режиме `document` у `IssuesPanel` колонка «место» не показывается; `OsvDocument` кладёт place '' / targetKey null (дата 2026-10-18). Обоснование: у документа нет места. Цена ошибки: нет.
437. **T10.D-3** (план 6): Подробности документа в поповере грузятся при открытии (`trigger=click`), состояние сбрасывается при закрытии (дата 2026-10-18). Обоснование: P6-12. Цена ошибки: лишний запрос при повторном открытии.
438. **T10.D-4** (план 6): Значок — `<button>` под плашкой статуса; без `documentId` раздел «Предупреждения документа» не показывается (дата 2026-10-18). Обоснование: правила a11y. Цена ошибки: нет.
439. **T10.D-5** (план 6): Пустой список в разделе «Предупреждения документа» — «Нет» (дата 2026-10-18). Обоснование: текст не из плана. Цена ошибки: только вид.
440. **UI-1** (план 6): Тексты, которых нет в плане: «Дата подтверждена (до сохранения)», «Подтверждение будет сохранено — нажмите, чтобы убрать», «Строка не найдена в форме», «Наименование организации — не длиннее 1000 символов» (дата 2026-10-18). Обоснование: нужны для состояний, плана для них не было. Цена ошибки: только формулировки.
441. **T11.D-1** (план 6): Колонки значений 128px вместо 112 (поле даты из 10 символов и сумма с галочкой обрезались), `tableLayout='fixed'` (дата 2026-10-18). Обоснование: замечание T11.1: колонка «прыгала» при появлении кнопки даты. Цена ошибки: только вид.
442. **T11.D-2** (план 6): Кнопка подтверждения даты — короткая «Подтвердить» / «Подтверждено», длинный текст «Подтвердить дату» / «Дата подтверждена (до сохранения)» в подсказке и `aria-label` (дата 2026-10-18). Обоснование: фиксированная ширина колонки; отклонение от формулировки «Подтвердить дату» в P6-11. Цена ошибки: только подпись.
443. **T11.D-3** (план 6): Ссылка «Разберите строки без кода: N» — `Button type=link` отдельной строкой над шапкой карточки, видна и в режиме правки (дата 2026-10-18). Обоснование: клавиатурный доступ; строки нужно разбирать именно при правке. Цена ошибки: только вид.
444. **T11.D-4** (план 6): Список предупреждений документа показывает первые 3 записи (ошибки первыми) и «Показать все (N)»; то же на экране ОСВ (дата 2026-10-18). Обоснование: таблица уезжала ниже сгиба. Цена ошибки: только вид.
445. **T11.D-5** (план 6): Значение со `string_value` без суммы показывается серым текстом, а не «-» (дата 2026-10-18). Обоснование: аналитик должен видеть нераспознанный текст. Цена ошибки: только вид.
## Открытые вопросы исполнения
Вопросы, поднятые при исполнении 18.10.2026 и не закрытые решениями владельца. Ответов здесь нет; источник — где лежит полный текст вопроса. Номера пунктов — из подраздела «Решения при исполнении» выше (где пункт перенесён).
- **O-2 / P3-30 (план 3Б, arm-core).** Пустая цель соотношения при непустых членах блокирует подтверждение формы, потерявшей итоговую строку (следствие P1-21 пакета). Источник: `arm-core`, `openspec/changes/unified-normalization-core-b/design.md`, P3-30; O-2 — в ledger плана 3.
- **P3-34 (план 3Б).** `NAME_MISMATCH` пересчитывается для всех кодов, а R-1 (Р-40) ограничивает только `CODE_NOT_IN_*`: пятизначная подстрока с уверенностью < 100 даёт `NAME_MISMATCH`. Источник: тот же `design.md` части Б, P3-34.
- **P3-36 (план 3Б).** Приём `1.x` пишет `form_variant` и записи `relation` / `soft`, но не `CODE_NOT_IN_*` / `NAME_MISMATCH` и не трогает `needs_review`; условие D-6 «проверки сошлись» для `1.x`. Источник: `design.md` части Б, P3-36.
- **P3-41: DISCLOSURE 2.0 (план 3Б).** `DISCLOSURE` в `2.0` — один документ на файл со статусом `RECOGNIZED`, предупреждения объединённых документов пишутся в его `document_issue`; уровень `error` на статус не влияет. Источник: `design.md` части Б, P3-41.
- **P3-52 (план 3Б).** `edition_for` (граница 66n/fsbu4 по дате для шаблона экрана) не переведён на `engine.edition(...)`: ответ формы и приём могут расходиться в редакции на формах без даты. Источник: `design.md` части Б, P3-52.
- **O-8.2 плана 4 (tabular-parser).** Строки НПЗ ОФР «Реализация иного имущества, прав требования…» состоят из прочерков (`[=0]"-"`): сценарий спеки «…с суммами» не соответствует файлу, пакет строки не выдаёт (R-10). Источник: ledger плана 4 (`.superpowers/sdd/2026-10-18-plan-4-tabular-parser/progress.md`), R-10 в `design.md` `unified-normalization-tp`.
- **R-8 плана 4.** «Пояснения» в адаптере: ячейка в строке-шапке с подписью «Код» не заголовок формы; шапка, где заголовок и «Код» в одной строке, теряет заголовок (T5.3). Владелец: оставить, посмотреть позже. Источник: `design.md` `unified-normalization-tp`, R-8.
- **Остаток I-2 правила Р-44.** Колонка OCR с потерянной подписью объединённой ячейки: настоящая колонка сумм без подписи может не считаться колонкой сумм. Источник: Р-44 выше; `arm-statements` #6 (3390f92), docs #123.
- **R-7 плана 5 (scan-gateway).** Смешанный набор json («ошибочный + успешный без таблиц / без документов») даёт `RECOGNITION_FAILED` «ни одной таблицы не разобрано», код первого ошибочного — только если успешных json нет. Источник: `scan-gateway`, `openspec/changes/statements-v2/design.md`, R-7 (3b9e1f6).
- **O-3 плана 5.** Одиночный суррогат в тексте ячейки OCR не чистится ни пакетом, ни шлюзом (было и раньше). Источник: ledger плана 5 (`.superpowers/sdd/2026-10-18-plan-5-scan-gateway/progress.md`), O-3, deferred.
- **R-3 и M-5 плана 1 (arm-statements).** R-3 (= I-2 задачи 4): `parse_code("1220|")` → `12201`, `"|1220"` → `11220`, результат `fixed=True`; M-5: смешанные разделители суммы дают «лучшую догадку» ×100. Оба — вопрос владельцу спеки. Источник: ledger плана 1 (`arm-statements/.superpowers/sdd/2026-10-18-plan-1-arm-statements/progress.md`).
- **M-7 плана 1.** `spec.md:13` («конвертацию делает хост») против P1-26 (пакет). Источник: ledger плана 1, M-7 (правка спеки в docs PR #117).
- **В-1 плана 6 (arm-web).** Приёмка экрана — по перехвату с фикстурой (P6-14) и регрессии на формах 1.x; проверка на реальных 2.0-данных (`aep_3`, РБ 1 кв. 2025) — позже, после перехода сервисов. Источник: план 6 (`plans/2026-10-18-plan-6-arm-web.md`, В-1).
- **В-3 плана 6.** Подтверждённая ячейка помечается маленькой серой галочкой (P6-15) — в спеке и макете вида нет. Источник: план 6, В-3.
- **P6-11 / T11.D-2 (план 6).** Короткая подпись кнопки даты «Подтвердить» / «Подтверждено» отклоняется от P6-11 («Подтвердить дату»). Источник: `arm-web`, `openspec/changes/statements-v2/design.md`, T11.D-2; ledger плана 6 (P11.6).
- **T11.9 / T10.2 (план 6).** `mapping_warnings` не входят в значок предупреждений панели файлов (deferred, P6-12). Источник: ledger плана 6 (`.superpowers/sdd/2026-10-18-statements-v2/progress.md`).
- **P6-19 (план 6).** Требования «Предупреждения ОСВ-документа» (блок `IssuesPanel` на экране ОСВ) нет в спеке docs. Источник: `arm-web`, `design.md` `statements-v2`, P6-19.