# Единый алгоритм нормализации ББ/ОФР (scan-gateway и tabular-parser) — дизайн Issue: arm-ka-docs#116. Статус: черновик, идёт разбор текущего процесса по шагам (brainstorming 17.10.2026). ## Цель Нормализация результата распознавания форм ББ/ОФР в scan-gateway (PDF → ИС Сканирование) и в tabular-parser (Excel) должна идти по одному алгоритму и отдавать единообразный результат, чтобы arm-core обрабатывал оба источника одним кодом. Повод — старые ошибки распознавания (AURORA-1406, 1462, 1511, 1517, 1519, 1522, 1523, 1524, 1634): разбор конвейера показал, что это не отдельные баги, а свойство конвейера (issue #116). ## Как работает сейчас Процесс разбит на шаги, общие для обоих сервисов: - **Шаг 0** — загрузка файла и доставка его до сервиса распознавания; - **Шаг 1** — подготовка данных: разбор (ИС Сканирование) или чтение (парсер) сырых данных и базовая разметка — какие в файле формы, какого типа, где их шапка и таблица; - **Шаг 2 и дальше** — дальнейшая обработка (раскладка колонок, строки, значения, проверки, статус) — ещё не описаны. ### Шаг 0. Загрузка и доставка до сервиса Общая часть (arm-core): 1. Пользователь загружает файл в карточку ЮЛ или в консолидацию (`primary_files.upload_one`): тип файла — по расширению и сигнатуре (`domain/file_types.py`), дедуп по `(ЮЛ, sha256)`, файл — в S3, запись `stored_file` и `recognition_job` со статусом `queued`. Конвейер задания: Excel (xls / xlsx / xlsb) → `tabular`, PDF / PNG / JPG → `image`. 2. Публикатор outbox (`recognition_exchange.publish_pending`) собирает запрос `recognition-request` — `job_id`, `file_id`, `pipeline`, `file_type`, `original_name`, ссылка на файл в S3, `reply_deadline`, `attempt` — и публикует его в subject конвейера: `tabular` → `files.tabular_uploaded`, `image` → `files.img_uploaded` (имена задаются окружением). Срок ответа по умолчанию: OCR — 20 мин, парсер — 5 мин. Нет ack 30 с — повторная публикация, после трёх повторов задание уходит в `dlq`, файл — `ERROR`. В сервисе (одинаково в scan-gateway и tabular-parser): 3. Запрос проверяется: JSON-объект, MAJOR версии контракта 1, `pipeline` свой (scan-gateway — только `image`), схема. Невалидный — `SCHEMA_MISMATCH` без ack. Повтор `job_id`, который уже в работе или обработан, — только повторный ack, второй обработки нет. 4. Ack `processing` публикуется до чтения S3; задание ставится в очередь сервиса. 5. Исходный файл читается из S3; не прочитан — `FILE_READ_ERROR`. scan-gateway дополнительно: бакет должен быть его единственным бакетом; если `reply_deadline` истёк до отправки — `TIMEOUT`. **Итог шага 0:** у сервиса есть байты исходного файла и запрос (`file_type`, `original_name`, `reply_deadline`). ### Шаг 1. Подготовка данных #### 1.1 Разбор и чтение сырых данных **scan-gateway** (`core/worker.py`, `gateway/`) — разбор делает ИС Сканирование, не мы: 1. Файл пишется во «Входящие» горячей папки как `.` (расширение — из `original_name`, нижний регистр; нет расширения — `RECOGNITION_FAILED`). Горячая папка недоступна — `RECOGNITION_FAILED`. 2. ИС распознаёт файл и кладёт в «Исходящие» `/` **один или несколько json** — по одному на распознанный комплект отчётности. 3. Шлюз опрашивает каталог каждые `SCAN_POLL_SECONDS` (5 с). Набор готов, когда в нём есть `*.json` и имена и размеры не менялись `SCAN_SETTLE_SECONDS` (2 с). Срок ожидания — `min(reply_deadline, запись во «Входящие» + SCAN_TIMEOUT_SECONDS)` (1200 с); не дождались — `TIMEOUT`. 4. Склейка (`gateway/merge.merge_answers`): каждый json разбирается независимо, по возрастанию имени → `{job_id, files: [{name, response, parse_error}]}`; json, который не разобрался, получает `parse_error`. Склейка сохраняется в S3 как `raw/{job_id}.json` **до** нормализации (не сохранилась — `UNKNOWN_ERROR`). Затем, только при `SCAN_CLEANUP=true`, горячая папка очищается. Что внутри одного json ИС (по реальным ответам `fixtures/ocr/*.json`, §11): - корень `{file_uid, error {code, message}, data_ocr}`; успех — `error.code == ""`, ошибка — непустой код (`INVALID_DOCUMENT`, `RECOGNITION_FAILED`, …), тогда таблиц нет; - `data_ocr`: поля шапки `label`, `organization`, `inn`, `units`, `date` — каждое `{form_name, confidence}`, сырой OCR-текст; **шапка одна на json**; - `data_ocr.table_structure[]` — таблицы форм, каждая `{label {form_name, confidence}, rows[]}`; строка — `{row_index, cells[]}`, ячейка — `{column_index, value, confidence}` (сырой текст, уверенность 0–100; `column_index` не обязательно подряд). Наблюдается: json с таблицами содержит **ББ и ОФР одного периода** под общей шапкой (`aep_3`, `aep_6`, `БО 1 кв 2025`, `Бухгалтерская отчетность 1 кв.25`). **Допущение владельца (17.10.2026):** если в файле отчётность за несколько периодов, ИС отдаёт её разными json; образца нет — проверить на первом таком файле. **tabular-parser** (`parser/book.py`, `parser/readers/`) — чтение делает сам парсер: 1. Размер файла больше `PARSE_MAX_BYTES` (50 МБ) — `UNSUPPORTED_FORMAT`. Отсчёт `PARSE_TIMEOUT_SECONDS` (120 с) — от начала разбора. 2. Формат определяется **по сигнатуре байтов**, а не по `file_type` запроса: OLE2 → XLS, zip с `xl/workbook.bin` → XLSB, иной zip → XLSX, прочее — `UNSUPPORTED_FORMAT`. Несовпадение с `file_type` — предупреждение. До чтения проверяются лимиты: распакованный размер zip, площадь объединённых ячеек. 3. Книга читается в листы `Sheet`: только непустые ячейки (текст, значение, числовой формат), объединённые диапазоны, уровни группировки строк, габариты. Библиотека чтения упала — `UNREADABLE`. У xlsb стили и группировка не читаются — предупреждение. #### 1.2 Базовая разметка **scan-gateway** (`normalizer/ocr/__init__.py`, `header.py`) — разметку уже сделала ИС, шлюз её только принимает: 1. json набора обходятся по порядку. Ошибочный json (`parse_error`, непустой `error.code`, корень не объект) — предупреждение `<имя>: <код>: <сообщение>`, json пропускается. Все json ошибочны — `error` с кодом первого. 2. Шапка json — из полей `data_ocr`: ИНН — только цифры, организация, дата (замена букв-двойников + разбор даты), единицы; у каждого поля — уверенность ИС. Предупреждения уровня файла: «ИНН не распознан», «ИНН распознан неуверенно», «дата отчёта не разобрана». **Шапка общая для всех таблиц json.** 3. Каждая таблица `table_structure[i]` — кандидат в документ формы. Тип — по `label` (`rules/statement_labels.yaml`); уточнение типа по кодам строк сейчас делается позже, в шаге 2 (после разбора строк). **tabular-parser** (`parser/detect.py`, `parser/statements/detect.py`, `header.py`) — разметку делает парсер, всё по содержимому (§12 п. 1, п. 6): 1. Вид каждого листа: признак ведомости в первых 30 строках — **ОСВ** (дальше свой путь, в этом change не рассматривается); иначе заголовок формы или ОКУД — **форма**; маркер «Форма … с.2», шапка «Код» с периодами или колонка кодов — **продолжение**; лист с 1–2 кодами сразу после листа формы — тоже продолжение; остальное — **прочее** (предупреждение «нет признаков ОСВ или формы»). 2. Формы на листе (`find_forms`): строки с заголовком («Бухгалтерский баланс», «Отчет о финансовых результатах», «…о прибылях и убытках») или ОКУД — кандидаты; кандидаты без строк с кодами между ними сливаются, заголовок после кодов открывает новую форму (две формы на одном листе). Тип — по ОКУД (0710001 → `BALANCE`, 0710002 → `INCOME`, прочие 0710xxx и «Пояснения…» → `DISCLOSURE`); заголовок и ОКУД противоречат — тип по ОКУД и предупреждение. Результат — блок формы: тип, `form_name`, `okud`, границы строк. 3. Шапка — **своя у каждой формы**, ищется в границах её блока: организация, ИНН (без ИНН аудитора), ОКПО, ОКОПФ, ОКФС, единицы, дата отчёта, адрес; найденное поле — уверенность 100, не найденное — `null`; предупреждения «в шапке не найдена организация», «в шапке не найдена единица измерения». 4. Листы-продолжения присоединяются к последней открытой форме — но решение требует раскладки колонок (совпадает ли число колонок сумм), поэтому фактически выполняется в шаге 2. **Итог шага 1:** у обоих сервисов есть список кандидатов в документы форм — тип, шапка и сырые строки таблицы. #### Различия на выходе шага 1 | | scan-gateway | tabular-parser | |---|---|---| | Кто размечает | ИС Сканирование | парсер, по содержимому | | Единица разметки | json (комплект одного периода) → таблицы | книга → листы → блоки форм | | Шапка | одна на json, общая для его таблиц | своя у каждой формы | | Тип формы | `label` таблицы (уточнение по кодам — в шаге 2) | ОКУД, иначе заголовок | | Уверенность | у каждого поля шапки и каждой ячейки, 0–100 | нет (найдено — 100) | | Многостраничная форма | одна таблица = одна форма, склейки нет | листы-продолжения присоединяются (в шаге 2) | | Ячейка | сырой OCR-текст | текст, значение и числовой формат |