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

61 KiB
Raw Blame History

Единый алгоритм нормализации ББ/ОФР (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 (число собрано из грязного текста), а не только с низкой уверенности; предложение — да: сошедшаяся проверка сильнее догадки о мусоре.