1280 lines
193 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…Р-26 — 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 остаётся открытым до образцов.
## 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` по статусу строки). Цена: нулевая.