From b86924bb93a426ae3f467902481150e025e2468a Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 17 Oct 2026 12:12:31 +0400 Subject: [PATCH] =?UTF-8?q?docs(#116):=20=D1=86=D0=B5=D0=BF=D0=BE=D1=87?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=88=D0=B0=D0=B3=D0=BE=D0=B2=200=E2=80=939=20?= =?UTF-8?q?=D1=81=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8=20=D0=B8=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=86=D0=B0=D0=BC=D0=B8=20=D1=80=D0=B0=D0=B7=D0=BB=D0=B8=D1=87?= =?UTF-8?q?=D0=B8=D0=B9,=20=D1=87=D1=82=D0=BE=20=D0=B4=D0=B5=D0=BB=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D1=8F=D0=B4=D1=80=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .../unified-statement-normalization/design.md | 397 +++++++++++++----- 1 file changed, 299 insertions(+), 98 deletions(-) diff --git a/openspec/changes/unified-statement-normalization/design.md b/openspec/changes/unified-statement-normalization/design.md index bfdceee..5bad7ee 100644 --- a/openspec/changes/unified-statement-normalization/design.md +++ b/openspec/changes/unified-statement-normalization/design.md @@ -5,127 +5,328 @@ Issue: arm-ka-docs#116. Статус: черновик, идёт разбор т ## Цель Нормализация результата распознавания форм ББ/ОФР в scan-gateway (PDF → ИС Сканирование) и в tabular-parser -(Excel) должна идти по одному алгоритму и отдавать единообразный результат, чтобы arm-core обрабатывал оба источника -одним кодом. Повод — старые ошибки распознавания (AURORA-1406, 1462, 1511, 1517, 1519, 1522, 1523, 1524, 1634): -разбор конвейера показал, что это не отдельные баги, а свойство конвейера (issue #116). +(Excel) должна идти по одной цепочке шагов. У каждого шага — определённый результат, который передаётся в следующий +шаг; результаты между шагами одинаковы для обоих сервисов (где это возможно). Все нестыковки собраны в таблицы +различий по каждому шагу. -## Как работает сейчас +Зачем: нормализовать каждый шаг; убрать бойлерплейт в arm-core (повторная дедупликация, пересчёт проверок, статус, +который ядро не может взять из результата); улучшить поддерживаемость (один словарь — одно место); улучшить +пользовательский опыт заказчика (старые ошибки распознавания AURORA-1406, 1462, 1511, 1517, 1519, 1522, 1523, 1524, 1634 +— свойство конвейера, а не отдельные баги, см. issue #116). -Процесс разбит на шаги, общие для обоих сервисов: +## Как работает сейчас: цепочка шагов -- **Шаг 0** — загрузка файла и доставка его до сервиса распознавания; -- **Шаг 1** — подготовка данных: разбор (ИС Сканирование) или чтение (парсер) сырых данных и базовая разметка — - какие в файле формы, какого типа, где их шапка и таблица; -- **Шаг 2 и дальше** — дальнейшая обработка (раскладка колонок, строки, значения, проверки, статус) — ещё не описаны. +Каждый шаг описан так: **вход → что делает 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): +**Вход:** файл пользователя. -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`. +Общая часть (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`. -В сервисе (одинаково в scan-gateway и tabular-parser): +В сервисе (одинаково): запрос проверяется (JSON-объект, MAJOR версии 1, свой `pipeline`, схема; иначе +`SCHEMA_MISMATCH` без ack); повтор `job_id` в работе — только повторный ack; ack `processing` до чтения S3; файл +читается из S3 (не прочитан — `FILE_READ_ERROR`). scan-gateway дополнительно: бакет должен быть его единственным; +`reply_deadline` истёк до отправки — `TIMEOUT`. -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`. +**Выход:** байты исходного файла + запрос (`file_type`, `original_name`, `reply_deadline`). -**Итог шага 0:** у сервиса есть байты исходного файла и запрос (`file_type`, `original_name`, `reply_deadline`). +**Различия:** нет, кроме сроков ответа и проверки бакета. ### Шаг 1. Подготовка данных +**Вход:** байты файла. + #### 1.1 Разбор и чтение сырых данных -**scan-gateway** (`core/worker.py`, `gateway/`) — разбор делает ИС Сканирование, не мы: +**scan-gateway** — разбор делает ИС Сканирование: +1. Файл пишется во «Входящие» горячей папки как `.` (нет расширения / папка недоступна — + `RECOGNITION_FAILED`). +2. ИС кладёт в «Исходящие» `/` один или несколько 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`) + папка очищается. -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 == ""`; `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; образца нет — проверить на первом таком файле. -Что внутри одного 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 стили и группировка не читаются — - предупреждение. +**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** (`normalizer/ocr/__init__.py`, `header.py`) — разметку уже сделала ИС, шлюз её только принимает: +**scan-gateway** — разметку сделала ИС, шлюз её принимает: ошибочный json (`parse_error`, непустой `error.code`, корень +не объект) — предупреждение `<имя>: <код>: <сообщение>` и пропуск; все json ошибочны — `error` с кодом первого. Шапка +json: ИНН — только цифры, организация, дата (двойники + разбор), единицы; уверенность ИС у каждого поля; +предупреждения уровня файла «ИНН не распознан» / «ИНН распознан неуверенно» / «дата отчёта не разобрана». Каждая +таблица `table_structure[i]` — кандидат в документ; тип — по `label` (`rules/statement_labels.yaml`). -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** — разметка по содержимому (§12 п. 1, 6): вид листа (признак ведомости в первых 30 строках — +ОСВ, отдельный путь; заголовок формы или ОКУД — форма; маркер «Форма … с.2», шапка «Код» с периодами, колонка кодов +или 1–2 кода сразу после листа формы — продолжение; иначе прочее с предупреждением). Формы на листе: строки с +заголовком («Бухгалтерский баланс», «Отчет о финансовых результатах», «…о прибылях и убытках») или ОКУД — +кандидаты; без кодов между ними сливаются; заголовок после кодов — новая форма. Тип по ОКУД (0710001 → `BALANCE`, +0710002 → `INCOME`, прочие 0710xxx и «Пояснения» → `DISCLOSURE`), заголовок спорит с ОКУД — ОКУД и предупреждение. +Шапка **своя у каждой формы** в границах блока: организация, ИНН (без ИНН аудитора), ОКПО, ОКОПФ, ОКФС, единицы, +дата, адрес; найдено — 100, нет — `null`; предупреждения «в шапке не найдена организация» / «…единица измерения». +Листы-продолжения присоединяются к последней открытой форме — но решение требует раскладки колонок (шаг 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 | +|---|---|---|---| +| 1 | Кто размечает | ИС Сканирование | парсер, по содержимому | +| 2 | Единица разметки | json (комплект одного периода) → таблицы | книга → листы → блоки форм | +| 3 | Шапка | одна на json, общая для его таблиц | своя у каждой формы | +| 4 | Тип формы | `label` таблицы (уточнение по кодам — шаг 6) | ОКУД, иначе заголовок | +| 5 | Уверенность | у каждого поля шапки и каждой ячейки, 0–100 | нет (найдено — 100) | +| 6 | Многостраничная форма | одна таблица = одна форма, склейки нет | листы-продолжения присоединяются (шаг 6) | +| 7 | Ячейка | сырой OCR-текст + `confidence` | текст, значение и числовой формат | -#### Различия на выходе шага 1 +### Шаг 2. Раскладка таблицы -| | scan-gateway | tabular-parser | -|---|---|---| -| Кто размечает | ИС Сканирование | парсер, по содержимому | -| Единица разметки | json (комплект одного периода) → таблицы | книга → листы → блоки форм | -| Шапка | одна на json, общая для его таблиц | своя у каждой формы | -| Тип формы | `label` таблицы (уточнение по кодам — в шаге 2) | ОКУД, иначе заголовок | -| Уверенность | у каждого поля шапки и каждой ячейки, 0–100 | нет (найдено — 100) | -| Многостраничная форма | одна таблица = одна форма, склейки нет | листы-продолжения присоединяются (в шаге 2) | -| Ячейка | сырой OCR-текст | текст, значение и числовой формат | +**Вход:** сырые строки таблицы одной формы. + +**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:;table:`. Шапка — поля json (общая). + +**tabular-parser** (`statement._document`, `merge`): лист-продолжение присоединяется к последней открытой форме, +если число колонок сумм совпало и тип в маркере не противоречит (слабое продолжение с 1–2 кодами — ещё и коды из +диапазона формы); не присоединился — одинокое продолжение с пустой шапкой и предупреждение. `locator = +sheet:,`. `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`. Предупреждения — +плоские строки `: table:: <текст>` в `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 | Формат предупреждений | `: table:: …` | `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::`. +У шлюза в конверте ещё `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, предупреждения), из которых пользователь видит +полтора. + +## Целевая картина + +### Шаг 1 — решения владельца по различиям (17.10.2026) + +1. **Кто размечает** — различие остаётся: у шлюза разметку делает ИС, у парсера — сам парсер. Это природа источников. +2. **Единица разметки** — на выходе шага 1 у обоих сервисов **одинаковый контракт**. Форма контракта (json или + промежуточные объекты в коде сервиса) — решается отдельно. +3. **Шапка** — у **каждой таблицы своя**. Шлюз отдаёт шапку json каждой его таблице. +4. **Тип формы** — определяется **единым подходом** в обоих сервисах (правило — открытый вопрос). +5. **Уверенность** — есть и у парсера, всегда 100: формат один, значение у Excel-пути постоянное. +6. **Многостраничная форма** — парсер **склеивает листы-продолжения в шаге 1**; на выходе шага 1 одна форма — одна + таблица, как у ИС. +7. **Ячейка** — **один общий формат** для обоих сервисов (состав — открытый вопрос). + +Открытые вопросы шага 1: форма контракта (п. 2), правило типа формы (п. 4), формат ячейки (п. 7), как парсеру склеивать +продолжения до шага 2 (п. 6: сейчас склейка опирается на раскладку колонок). + +Шаги 2–8 целевой картины — по ходу brainstorming, по той же схеме: решение по каждой строке таблицы различий.