2026-10-17 18:13:39 +04:00

584 lines
61 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…Р-6 (brainstorming 17.10.2026).
## Цель
Нормализация результата распознавания форм ББ/ОФР в 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 | Что считается кодом при поиске столбца | любое целое число (`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». Решения владельца помечены датой; остальное —
предложение (17.10.2026), решения, которые нужны от владельца, собраны в конце (Р-1…Р-6). Имена полей — рабочие,
уточняются при написании спеки.
Принципы, из которых выведены решения по строкам:
1. **Один выход на шаг.** Код, зависящий от источника, живёт только в шаге 1 (адаптеры «json ИС → кандидат», «лист
Excel → кандидат»). Шаги 2–8 — общий код с общими словарями (форма общего кода — Р-1).
2. **Ничего не выбрасывать молча.** Строка, колонка или ячейка, которую не удалось истолковать, идёт дальше со
статусом, а не превращается в счётчик; пользователь её видит и исправляет.
3. **Качество — структурно и с привязкой к месту:** уверенность ячейки (от источника), `is_recognized` (разбор числа),
статус строки, нарушения §7 по ячейкам, предупреждения с уровнем и привязкой. Статус документа сервис выводит из
них сам; ядро не пересчитывает.
4. **Один словарь — одно место:** строки форм, проверки §7, периоды, заголовки форм, диапазоны кодов.
5. **Очистка OCR (§11.1) — не отдельный шаг, а первая стадия типовых разборов текста** в общем коде, там, где уже
известен смысл ячейки: ИНН и дата шапки (шаг 1), подписи колонок (шаг 2), код строки и разлепление склеенных
строк (шаг 3), сумма (шаг 4). Разбор вызывается для любой ячейки без `value`; у Excel он срабатывает только на
текстовых ячейках и почти всегда ничего не меняет. Факт исправления фиксируется там же: `is_recognized = false`
у значения, `unreadable` у кода, предупреждение у даты. Из `normalizer/ocr/` шлюза `text`, `dates`, `amounts`
становятся общими разборами, `layout`, `rows`, `header`, `document` — шагами 2–6 общего кода.
### Шаг 1 — Подготовка данных
**Решения владельца (17.10.2026):** 1) кто размечает — различие остаётся; 2) на выходе одинаковый контракт;
3) шапка своя у каждой таблицы; 4) тип формы — единый подход; 5) уверенность есть и у парсера, всегда 100;
6) парсер склеивает продолжения в шаге 1; 7) ячейка — один формат.
**Вход:** байты файла (шаг 0).
**Единый выход:** список кандидатов в форму на файл, `FormCandidate`:
- `source` — `ocr` | `excel`;
- `locator` — откуда взят (имя json и номер таблицы / имена листов);
- `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`;
- `warnings` — предупреждения шага (формат — шаг 8).
Ячейка `Cell` одна для шапки и таблицы: `text` (как показывает источник), `value` (число или дата, если источник их
знает; у OCR — `null`), `confidence` 0–100 (у Excel — 100), `ref` (позиция в источнике для сообщений: `row_index` /
`column_index` у OCR, адрес A1 у Excel).
- **1. Кто размечает** — остаётся, фиксируется полем `source`
- scan-gateway: без изменений
- tabular-parser: без изменений
- **2. Единица разметки** — файл → список кандидатов; шаги 2–8 работают только с кандидатом (Р-1 — форма общего кода)
- scan-gateway: адаптер «таблица json → кандидат»
- tabular-parser: адаптер «блок формы на листе → кандидат»
- **3. Шапка** — у каждого кандидата своя; поля = поля `header` контракта, каждое — `Cell`; разбор текста поля (ИНН → цифры, дата → `date`, единицы → словарь) — общий код по `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» или по позиции листа; проверка «число колонок сумм совпало» из склейки уходит (повторная шапка внутри сетки — правило шага 2, общее для обоих: у OCR она и так внутри таблицы); тип из маркера сверяется с `type_hint`, расхождение — отдельный кандидат + предупреждение
- 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` (сетка, `type_hint`).
**Единый выход:** `TableLayout` — столбец кода, столбец наименования, колонки значений `[{col, label, period_end}]`,
роль каждой строки сетки (`header` | `numbering` | `data`), сегменты (участки сетки между повторными шапками, у
каждого своя раскладка колонок).
- **1. Что считается кодом при поиске столбца** — одно правило: 4–5 цифр (допускается `.0`), первые четыре — в диапазонах словаря кодов всех форм (`type_hint` неизвестен — объединение диапазонов); столбец с наибольшим числом кодов, кодов ≥ 3, доля среди непустых ≥ порога; нет — «колонка кодов не найдена»
- scan-gateway: `int(text)` → правило словаря (закрывает AURORA-1517 вместе с п. 5)
- tabular-parser: диапазон формы → объединение; `relaxed` для одиноких продолжений не нужен (они склеены в шаге 1)
- **2. Повторная шапка внутри таблицы** — одно правило: строка внутри сетки с признаком шапки («Код» / та же сигнатура) открывает новый сегмент; у сегмента своя раскладка (позиции колонок могут сдвинуться); колонки значений сегментов сопоставляются по порядку; число не совпало — предупреждение, значения по позиции
- scan-gateway: как сейчас, через общее правило
- tabular-parser: добавляет: бывший «лист-продолжение со своей раскладкой» становится сегментом
- **3. Дата колонки** — один словарь периодов (`statement_periods.yaml`) → `period_end`; перед разбором — очистка двойников OCR (общий шаг, у Excel не срабатывает)
- scan-gateway: `parse_date` «размером с дату» → словарь
- tabular-parser: добавляет очистку двойников (no-op)
- **4. Без шапки** — одно правило: строка заголовка не найдена → колонки значений = столбцы правее кода, где в строках с кодами стоят числа; `label` пустая, `period_end` `null`; одно предупреждение «строка заголовка не найдена»
- scan-gateway: синтез шапки уходит (он эквивалентен)
- tabular-parser: добавляет предупреждение
- **5. Строка нумерации «1 2 3 4 5»** — одно правило `is_numbering_row` → роль `numbering`, в данные не попадает
- scan-gateway: добавляет (AURORA-1517)
- tabular-parser: как сейчас
- **6. Подпись без периода** — одно правило: подпись — период по словарю → колонка с датой; подпись из словаря исключений («Изменение», «Отклонение», «%», «Темп», «Доля») → пропуск с предупреждением info; иначе, если в столбце есть числа в строках с кодами — колонка без даты + предупреждение (Р-6); иначе пропуск
- scan-gateway: добавляет словарь исключений
- tabular-parser: перестаёт молча пропускать неизвестные числовые колонки
### Шаг 3 — Отбор строк данных
**Вход:** `FormCandidate` + `TableLayout`.
**Единый выход:** `RawLine[]` — по строке на каждую строку сетки с ролью `data`, у которой есть код или хотя бы одно
число: `ref`, ячейка кода, `code_status` (`ok` | `unreadable` — код есть, но не разобран | `missing` — кода нет, суммы
есть | `duplicate` — код уже встречался), ячейка наименования, ячейки значений по колонкам раскладки. Строка без кода
и без чисел (заголовок раздела «АКТИВ», «в том числе:») — не строка. Счётчиков отброшенных нет — есть статусы.
- **1. Код-мусор** — статус `unreadable`, строка сохраняется с сырым текстом кода (AURORA-1462: «ИЧ,250» видна, аналитик назначает 1250)
- scan-gateway: счётчик → статус
- tabular-parser: отличает «нет кода» от «не разобран»
- **2. Склейка двух строк в одну** — одно правило: ячейка кода содержит N кодов через перевод строки и ячейки значений — N частей → N строк (OQ-156); у Excel не срабатывает
- scan-gateway: как сейчас, через общее правило
- tabular-parser: правило общее, no-op
- **3. Повтор кода** — статус `duplicate` у второй и последующих строк; строки сохраняются; текстовое предупреждение и `checks.duplicate_line_codes` уходят; что показывать и как считать — ядро (Р-4)
- scan-gateway: «оставлена первая» → обе строки
- tabular-parser: `duplicates` → статус
- **4. Отброшенная строка** — строки не отбрасываются: `unreadable` / `missing` / `duplicate` идут дальше со статусом (AURORA-1634, 1462, 1406); ядро не достраивает вместо них пустую шаблонную строку молча
- scan-gateway: счётчики → статусы
- tabular-parser: счётчики → статусы
### Шаг 4 — Значения ячеек
**Вход:** ячейки значений `RawLine` по колонкам раскладки (`Cell` или `null`).
**Единый выход:** `StatementValue {column_index, amount, string_value, confidence, is_recognized}` — как в контракте,
с одним смыслом полей у обоих.
- **1. Предобработка текста** — у ячейки после шага 1 либо есть `value`, либо только `text`. `value` есть → `amount = value` (2 знака), `is_recognized = true`. `value` нет → один `parse_amount(text)` для обоих: двойники, разделители тысяч, скобки → минус, прочерк → `null`; Excel-ячейки «число текстом» идут тем же путём
- scan-gateway: `parse_amount` переезжает в общий код
- tabular-parser: `to_money` заменяется общим `parse_amount`
- **2. Смысл `is_recognized`** — один: текст разобран как число без выброшенных и исправленных символов; `confidence` — отдельно, от источника; экран подсвечивает по любому из двух (как сейчас)
- scan-gateway: отвязывает от `confidence`
- tabular-parser: как сейчас
- **3. «42831 1»** — в `parse_amount` пробел допустим только как разделитель тысяч (группы ровно по три цифры); иначе `amount` — лучшая догадка (цифры подряд), `is_recognized = false`, `string_value` — сырой текст (AURORA-1511, 1519)
- scan-gateway: меняется через общий код
- tabular-parser: то же правило, у Excel почти не срабатывает
- **4. Единицы** — одно правило: единицы «тыс. руб.» и дробных ≥ 10 % → предупреждение документа
- scan-gateway: добавляет
- tabular-parser: как сейчас
- **5. Пустая ячейка** — одно правило: позиции нет, текст пуст или прочерк → `amount = null`, `string_value = null`, `is_recognized = true`, `confidence` ячейки (нет ячейки — 100). Потерянную OCR-ячейку ловят проверки §7 (Р-2)
- scan-gateway: «ячейки нет → 0, не распознано» уходит
- tabular-parser: как сейчас
### Шаг 5 — Код, наименование, редакция
**Вход:** `RawLine` (статус, ячейки кода и наименования) + `StatementValue[]`.
**Единый выход:** `StatementLine {line_status, line_code, line_code_text, line_code_confidence, line_name,
line_name_confidence, values}`: `line_code` — разобранный код (4–5 цифр) или `null` при `unreadable` / `missing`;
`line_code_text` — сырой текст ячейки кода; `line_status` = `code_status` шага 3.
- **1. Источник наименования** — одно правило: наименование — из файла, с уверенностью источника; справочник наименование не подменяет. Справочное имя по коду и редакции добавляет ядро из своего (единственного) словаря, экран показывает справочное, файловое — рядом (Р-3; AURORA-1406, 1522)
- scan-gateway: имя из `statement_lines.yaml` → имя из OCR
- tabular-parser: как сейчас
- **2. Редакция (66н / ФСБУ 4)** — в сервисах не определяется; редакция — свойство словаря строк (по дате отчёта), используется в шаге 7 (проверка «код не из редакции формы») и в ядре при достройке шаблонных строк
- scan-gateway: `_choose_edition` уходит
- tabular-parser: без изменений
- **3. Справочник строк** — один словарь строк (в общем коде, Р-1); копия в шлюзе удаляется
- scan-gateway: удаляет `api/rules/statement_lines.yaml`
- tabular-parser: без изменений
- **4. Уверенность кода** — от источника (ИС / 100), без срезания до 99 по справочнику; «не в справочнике» — предупреждение к строке в шаге 7
- scan-gateway: убирает `min(conf, 99)`
- tabular-parser: как сейчас
### Шаг 6 — Сборка документа
**Вход:** `FormCandidate` (`type_hint`, `evidence`, `header`) + `TableLayout` + `StatementLine[]`.
**Единый выход:** `StatementDocument {document_type, locator, header, columns, lines}` без `checks`.
- **1. Тип формы** — одно правило: большинство кодов 1xxx → `BALANCE`, 2xxx → `INCOME`; совпало с `type_hint` или `type_hint` `null` — тип по кодам; противоречит — тип по кодам + предупреждение; кодов нет — `type_hint`; без колонки кодов: `type_hint` есть → документ без строк (`DISCLOSURE` — `done`, иначе `needs_check`), `type_hint` нет → не документ, предупреждение info
- scan-gateway: как сейчас, через общее правило
- tabular-parser: добавляет сверку с кодами
- **2. Склейка страниц** — решена в шаге 1, различия нет
- scan-gateway: без изменений
- tabular-parser: без изменений
- **3. `locator`** — одна грамматика «пары `ключ:значение` через `;`», набор ключей по источнику: `json:<имя>;table:<i>` / `sheet:<A>[,<B>]`
- scan-gateway: без изменений
- tabular-parser: без изменений
Шапка документа — поля `header` кандидата как есть (`text` → `*_text`, `value` → поле, `confidence` → `*_confidence`).
### Шаг 7 — Самопроверки
**Вход:** `StatementDocument`.
**Единый выход:** `StatementChecks` у обоих: `relations_failed[]`, `soft_relations_failed[]` (каждое нарушение — с
кодами слагаемых и `column_index`, чтобы экран подсветил ячейки), `line_warnings[]` (код не из редакции формы по дате
отчёта, код не в словаре). `duplicate_line_codes` уходит в `line_status`.
- **1. Проверки только у парсера; словарь §7 в двух копиях; ядро результат не читает** — проверки — общий код с одним словарём `statement_checks.yaml` у обоих сервисов; для OCR нарушение §7 — главный признак неверно прочитанной цифры; ядро читает `checks` из результата и пересчитывает тем же кодом только после правок аналитика (PATCH)
- scan-gateway: добавляет шаг
- tabular-parser: `run_statement_checks` → общий код
### Шаг 8 — Статус и предупреждения
**Вход:** `StatementDocument` + `StatementChecks` + предупреждения шагов 1–7.
**Единый выход:** у каждого документа `status` (`done` | `needs_check`) и `warnings[]`; у файла `metrics.warnings[]`;
статус конверта — агрегат. Предупреждение — структура `{code, severity: error | info, message, line_ref?,
column_index?}`, а не строка.
- **1. Критерий `done`** — один критерий из самого результата: раскладка найдена ∧ (организация или ИНН) ∧ дата отчёта ∧ все строки `ok` ∧ нет значений `needs_review` (см. «Подтверждение проверкой» ниже) ∧ жёсткие проверки §7 сошлись ∧ нет предупреждений уровня `error`. Excel выполняет условия уверенности автоматически
- scan-gateway: «ничего не выброшено и уверенность 100» → общий критерий
- tabular-parser: `_is_ok` → общий критерий; добавляются строки и значения
- **2. Влияние предупреждений** — по уровню: `error` → `needs_check`, `info` — не влияет; у обоих одинаково
- scan-gateway: «любое → `needs_check`» уходит
- tabular-parser: предупреждения начинают влиять
- **3. Статус документа** — в контракте: `document.status`; конверт `done` ⇔ все документы `done` ∧ нет предупреждений файла уровня `error`; иначе `needs_check`; документов нет — `error` (как сейчас)
- scan-gateway: добавляет
- tabular-parser: внутренний `ok` → в контракт
- **4. Формат предупреждений** — структура (см. выше) вместо строк `<json>: table:<i>: …` / `sheet=<имя> …`; место — в `locator` документа, а не в тексте
- scan-gateway: меняет
- tabular-parser: меняет
- **5. Привязка** — к документу, строке (`line_ref`) или ячейке (`line_ref` + `column_index`); экран показывает у места
- scan-gateway: меняет
- tabular-parser: меняет
**Подтверждение проверкой (вопрос владельца 17.10.2026).** Проверка §7 — арбитр, уверенность — подсказка там, где
проверки нет. По каждому значению сервис считает `needs_review`:
- `true`, если `is_recognized = false` или `confidence` ниже порога (Р-5) **и** значение не покрыто сошедшимся жёстким
соотношением §7 в своей колонке (не входит ни в одно, соотношение не сошлось или оно мягкое);
- `true` у всех ячеек соотношения в колонке, где оно не сошлось, независимо от уверенности;
- иначе `false`: значение подтверждено проверкой, не подсвечивается и статус не трогает; сырой текст и уверенность
остаются в подсказке ячейки.
Экран подсвечивает ячейки только по `needs_review`; после PATCH ядро пересчитывает `checks` и `needs_review` общим
кодом. Не покрыто проверками и остаётся на уверенности: поля шапки, справочные строки ОФР 25xx / 29xx, детализация
без проверки родителя, `DISCLOSURE`, колонки, где есть только мягкие соотношения. Риск — две взаимно компенсирующие
ошибки в одном соотношении; принимается (перекрёстные соотношения баланса его снижают). Закрывает AURORA-1524 без
ущерба для 1519. Распространение на `is_recognized = false` — Р-7.
### Шаг 9 — Результат
Одинаково, как сейчас; контракт `recognition-result.schema.json` → **v1.3**: `line_status`, `line_code` nullable,
`line_code_text`; `document.status`; `document.warnings[]` и `metrics.warnings[]` структурные; `checks` обязателен для
`BALANCE` / `INCOME` у обоих конвейеров, нарушения с кодами слагаемых; `line_warnings`.
### Что уходит из ядра
- `build_statement`: повторная дедупликация колонок, кодов и значений (контракт гарантирует уникальность колонок;
повторы строк приходят со статусом `duplicate`).
- `statement_status` (D-6): `RECOGNIZED` ⇔ `document.status = done` ∧ ИНН ≠ `MISMATCH` (единственное знание ядра);
остальные признаки D-6 не пересчитываются.
- §7 при загрузке: `checks` берутся из результата; пересчёт общим кодом — только после PATCH.
- Словарь строк и словарь §7: единственный экземпляр в общем коде (Р-1); копии ядра и шлюза удаляются.
- Достройка шаблонных строк: по тому же словарю; строки `missing` / `unreadable` / `duplicate` показываются как
пришли, с подсветкой.
- Экран: подсветка ячеек только по `needs_review` (уверенность, `is_recognized` и нарушения §7 уже сведены сервисом);
строк — по `line_status`; предупреждения документа — с привязкой к месту.
### Решения, которые нужны от владельца
- **Р-1** (шаг 1, п. 2) — форма общего кода: общий Python-пакет с dataclass-ами (сериализуемыми в json — golden-файлы
выхода шага 1 в fixtures), шагами 2–8 и словарями, который подключают оба сервиса и ядро (рекомендация); запасной
вариант, если платформа не даст ставить пакет из git или зеркала, — копия по скрипту, как `sync-web.sh`.
- **Р-2** (шаг 4, п. 5) — отсутствующая OCR-ячейка под колонкой: чистый `null` (предложение; потерю ловит §7) или
подсветка «не распознано», как сейчас (больше ложных подсветок, AURORA-1524).
- **Р-3** (шаг 5, п. 1) — наименование: из файла в сервисах, справочное — в ядре (= решение № 2 по спринту 2,
AURORA-1406 / 1522; договорённость ДБД «из справочника» выполняется на экране).
- **Р-4** (шаг 3, п. 3) — повторы кодов: сервисы отдают обе строки со статусом; показ обеих и сумма по договорённости
ДБД (AURORA-1431) — в ядре и на экране.
- **Р-5** (шаг 8, п. 1) — порог уверенности для `done` (= решение № 7 по спринту 2, AURORA-1519 / 1524); предложение —
100, как сейчас.
- **Р-6** (шаг 2, п. 6) — неизвестная числовая колонка без периода: оставлять без даты с предупреждением (`needs_check`)
или пропускать, как парсер сейчас.
- **Р-7** (шаг 8, «Подтверждение проверкой») — снимать ли подсветку сошедшимся соотношением §7 и с `is_recognized =
false` (число собрано из грязного текста), а не только с низкой уверенности; предложение — да: сошедшаяся проверка
сильнее догадки о мусоре.