2026-10-17 11:48:10 +04:00

132 lines
13 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. Статус: черновик, идёт разбор текущего процесса по шагам (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. Файл пишется во «Входящие» горячей папки как `<job_id>.<ext>` (расширение — из `original_name`, нижний регистр; нет
расширения — `RECOGNITION_FAILED`). Горячая папка недоступна — `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. Склейка (`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-текст | текст, значение и числовой формат |