13 KiB
Единый алгоритм нормализации ББ/ОФР (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):
- Пользователь загружает файл в карточку ЮЛ или в консолидацию (
primary_files.upload_one): тип файла — по расширению и сигнатуре (domain/file_types.py), дедуп по(ЮЛ, sha256), файл — в S3, записьstored_fileиrecognition_jobсо статусомqueued. Конвейер задания: Excel (xls / xlsx / xlsb) →tabular, PDF / PNG / JPG →image. - Публикатор 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):
- Запрос проверяется: JSON-объект, MAJOR версии контракта 1,
pipelineсвой (scan-gateway — толькоimage), схема. Невалидный —SCHEMA_MISMATCHбез ack. Повторjob_id, который уже в работе или обработан, — только повторный ack, второй обработки нет. - Ack
processingпубликуется до чтения S3; задание ставится в очередь сервиса. - Исходный файл читается из 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/) — разбор делает ИС Сканирование, не мы:
- Файл пишется во «Входящие» горячей папки как
<job_id>.<ext>(расширение — изoriginal_name, нижний регистр; нет расширения —RECOGNITION_FAILED). Горячая папка недоступна —RECOGNITION_FAILED. - ИС распознаёт файл и кладёт в «Исходящие»
<job_id>/один или несколько json — по одному на распознанный комплект отчётности. - Шлюз опрашивает каталог каждые
SCAN_POLL_SECONDS(5 с). Набор готов, когда в нём есть*.jsonи имена и размеры не менялисьSCAN_SETTLE_SECONDS(2 с). Срок ожидания —min(reply_deadline, запись во «Входящие» + SCAN_TIMEOUT_SECONDS)(1200 с); не дождались —TIMEOUT. - Склейка (
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/) — чтение делает сам парсер:
- Размер файла больше
PARSE_MAX_BYTES(50 МБ) —UNSUPPORTED_FORMAT. ОтсчётPARSE_TIMEOUT_SECONDS(120 с) — от начала разбора. - Формат определяется по сигнатуре байтов, а не по
file_typeзапроса: OLE2 → XLS, zip сxl/workbook.bin→ XLSB, иной zip → XLSX, прочее —UNSUPPORTED_FORMAT. Несовпадение сfile_type— предупреждение. До чтения проверяются лимиты: распакованный размер zip, площадь объединённых ячеек. - Книга читается в листы
Sheet: только непустые ячейки (текст, значение, числовой формат), объединённые диапазоны, уровни группировки строк, габариты. Библиотека чтения упала —UNREADABLE. У xlsb стили и группировка не читаются — предупреждение.
1.2 Базовая разметка
scan-gateway (normalizer/ocr/__init__.py, header.py) — разметку уже сделала ИС, шлюз её только принимает:
- json набора обходятся по порядку. Ошибочный json (
parse_error, непустойerror.code, корень не объект) — предупреждение<имя>: <код>: <сообщение>, json пропускается. Все json ошибочны —errorс кодом первого. - Шапка json — из полей
data_ocr: ИНН — только цифры, организация, дата (замена букв-двойников + разбор даты), единицы; у каждого поля — уверенность ИС. Предупреждения уровня файла: «ИНН не распознан», «ИНН распознан неуверенно», «дата отчёта не разобрана». Шапка общая для всех таблиц json. - Каждая таблица
table_structure[i]— кандидат в документ формы. Тип — поlabel(rules/statement_labels.yaml); уточнение типа по кодам строк сейчас делается позже, в шаге 2 (после разбора строк).
tabular-parser (parser/detect.py, parser/statements/detect.py, header.py) — разметку делает парсер, всё по
содержимому (§12 п. 1, п. 6):
- Вид каждого листа: признак ведомости в первых 30 строках — ОСВ (дальше свой путь, в этом change не рассматривается); иначе заголовок формы или ОКУД — форма; маркер «Форма … с.2», шапка «Код» с периодами или колонка кодов — продолжение; лист с 1–2 кодами сразу после листа формы — тоже продолжение; остальное — прочее (предупреждение «нет признаков ОСВ или формы»).
- Формы на листе (
find_forms): строки с заголовком («Бухгалтерский баланс», «Отчет о финансовых результатах», «…о прибылях и убытках») или ОКУД — кандидаты; кандидаты без строк с кодами между ними сливаются, заголовок после кодов открывает новую форму (две формы на одном листе). Тип — по ОКУД (0710001 →BALANCE, 0710002 →INCOME, прочие 0710xxx и «Пояснения…» →DISCLOSURE); заголовок и ОКУД противоречат — тип по ОКУД и предупреждение. Результат — блок формы: тип,form_name,okud, границы строк. - Шапка — своя у каждой формы, ищется в границах её блока: организация, ИНН (без ИНН аудитора), ОКПО, ОКОПФ, ОКФС,
единицы, дата отчёта, адрес; найденное поле — уверенность 100, не найденное —
null; предупреждения «в шапке не найдена организация», «в шапке не найдена единица измерения». - Листы-продолжения присоединяются к последней открытой форме — но решение требует раскладки колонок (совпадает ли число колонок сумм), поэтому фактически выполняется в шаге 2.
Итог шага 1: у обоих сервисов есть список кандидатов в документы форм — тип, шапка и сырые строки таблицы.
Различия на выходе шага 1
| scan-gateway | tabular-parser | |
|---|---|---|
| Кто размечает | ИС Сканирование | парсер, по содержимому |
| Единица разметки | json (комплект одного периода) → таблицы | книга → листы → блоки форм |
| Шапка | одна на json, общая для его таблиц | своя у каждой формы |
| Тип формы | label таблицы (уточнение по кодам — в шаге 2) |
ОКУД, иначе заголовок |
| Уверенность | у каждого поля шапки и каждой ячейки, 0–100 | нет (найдено — 100) |
| Многостраничная форма | одна таблица = одна форма, склейки нет | листы-продолжения присоединяются (в шаге 2) |
| Ячейка | сырой OCR-текст | текст, значение и числовой формат |