Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
1280 lines
193 KiB
Markdown
1280 lines
193 KiB
Markdown
# Единый алгоритм нормализации ББ/ОФР (scan-gateway и tabular-parser) — дизайн
|
||
|
||
Issue: arm-ka-docs#116. Статус: **финальная версия** — целевая картина по шагам 1–9 согласована с владельцем
|
||
(решения Р-1…Р-19, 17.10.2026) и прошла три раунда независимого ревью (`review-2026-10-17.md`,
|
||
`review-2026-10-17-closure.md`, `review-2026-10-17-blind.md`, `review-2026-10-17-closure-2.md`,
|
||
`review-2026-10-17-blind-2.md`). Спека (дельты OpenSpec, контракт v2.0, §7/§9/§11 в `05-domain-rules.md`, DBML v5.19)
|
||
написана 18.10.2026 (решения S-1…S-25), прошла ревью вслепую (`review-2026-10-18-spec.md`, находки K-1…K-25) и
|
||
доработана по нему (решения владельца Р-20…Р-22, решения S-26…S-52 ниже), прошла повторное ревью вслепую
|
||
(`review-2026-10-18-spec-2.md`, находки L-1…L-30) и доработана по нему (решения владельца Р-23…Р-26, решения
|
||
S-53…S-74 ниже). Где текст дизайна выше расходится с решениями Р-20…Р-26 и S-n, действуют они и спека.
|
||
|
||
## Цель
|
||
|
||
Нормализация результата распознавания форм ББ/ОФР в 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 | Что считается кодом при поиске столбца | столбец — по справочнику кодов (`infer_layout`), строка — любое целое (`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». Решения владельца собраны в конце
|
||
(Р-1…Р-19, все приняты 17.10.2026). Текст прошёл три раунда независимого ревью (`review-2026-10-17*.md` рядом с этим
|
||
файлом); находки внесены. Имена полей — рабочие, уточняются при написании спеки.
|
||
|
||
Принципы, из которых выведены решения по строкам:
|
||
|
||
1. **Один выход на шаг.** Код, зависящий от источника, живёт только в шаге 1 (адаптеры «json ИС → кандидат», «лист
|
||
Excel → кандидат»). Шаги 2–8 — общий код с общими словарями (форма общего кода — Р-1). Единственное место, где
|
||
общий код смотрит на источник, — разбор запятой и точки в сумме (шаг 4).
|
||
2. **Ничего не выбрасывать молча.** Строка, колонка или ячейка, которую не удалось истолковать, идёт дальше со
|
||
статусом, а не превращается в счётчик; пользователь её видит и исправляет. Положение в источнике (`ref`), порядок
|
||
строк (`row_index`) и сырой текст ячейки (`text`) доезжают до базы и экрана. Подтвердить документ, не разобрав
|
||
строки с суммами без кода, нельзя (Р-13).
|
||
3. **Качество — структурно и с привязкой к месту:** уверенность ячейки (от источника), `is_recognized` (разбор числа),
|
||
статус строки, нарушения §7 по ячейкам, предупреждения с уровнем и привязкой. Уверенность, `is_recognized` и §7
|
||
сервис сводит в один признак `needs_review` у значения, строки и поля шапки (шаг 8) и из них выводит статус
|
||
документа. Ядро при загрузке не пересчитывает ни проверки, ни статус, ни `needs_review`: хранит их как пришли и
|
||
добавляет только предупреждения `info` по своему словарю имён. После правок аналитика ядро считает тем же общим
|
||
кодом, добавляя своё знание: правка или подтверждение снимает `needs_review`, акцепт снимает жёсткие нарушения,
|
||
квартал периода, сверка ИНН и наименований с справочником — условия `RECOGNIZED`.
|
||
4. **Один словарь — одно место:** проверки §7 (полный и упрощённый наборы), периоды, заголовки форм, упорядоченные
|
||
перечни кодов форм по редакциям и вариантам (без наименований), диапазоны кодов — в общем коде сервисов;
|
||
соответствие «код → наименование» — только в ядре, сервисы его не знают и отдают распознанный текст (Р-3). Порядок и
|
||
состав редакций ядро берёт из своей копии общего кода, словарь ядра хранит только имена; `sync-statements --check`
|
||
сверяет, что все коды словаря ядра есть в перечне.
|
||
5. **Очистка OCR (§11.1) — не отдельный шаг, а первая стадия типовых разборов текста** в общем коде, там, где уже
|
||
известен смысл ячейки: дата шапки (шаг 1), подписи колонок (шаг 2), код строки и разлепление склеенных строк
|
||
(шаг 3), сумма (шаг 4). Буквы-двойники («О» → 0, «З» → 3, «б» → 6, «S» → 5, «I», «l», «|» → 1) заменяются в датах,
|
||
подписях, кодах и суммах (Р-11, отменяет §11.1 п. 1 и решение № 22 среза 3a «только даты»): сумма с заменой
|
||
получает `is_recognized = false`, код с заменой считается неуверенным (эффективная уверенность ниже 100), а дальше
|
||
их судьбу решают контрольные соотношения §7 и структурная проверка кода. В суммах крайние «|» (след границы
|
||
таблицы: «| : 122477») убираются, а не заменяются. ИНН двойники не трогают: только цифры, как сейчас — иначе
|
||
«3|1 21 2|» из aep_3 даёт лишние единицы и `MISMATCH` с ЮЛ. Разбор вызывается для любой ячейки без `value`; у Excel
|
||
он срабатывает только на текстовых ячейках и почти всегда ничего не меняет. Из `normalizer/ocr/` шлюза `text`,
|
||
`dates`, `amounts` становятся общими разборами, `layout`, `rows`, `header`, `document` — шагами 2–6 общего кода.
|
||
|
||
### Шаг 1 — Подготовка данных
|
||
|
||
**Решения владельца:** 1) кто размечает — различие остаётся; 2) на выходе одинаковый контракт; 3) шапка своя у
|
||
каждой таблицы; 4) тип формы — единый подход; 5) уверенность есть и у парсера, всегда 100; 6) парсер склеивает
|
||
продолжения в шаге 1; 7) ячейка — один формат.
|
||
|
||
**Вход:** байты файла (шаг 0).
|
||
|
||
**Единый выход:** список кандидатов в форму на файл, `FormCandidate`:
|
||
|
||
- `source` — `ocr` | `excel`;
|
||
- `locator` — откуда взят: `file:<имя json>;table:<i>` (как в §11 и golden-эталонах) / `sheet:<A>[,<B>]`;
|
||
- `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`;
|
||
- `segment_breaks` — номера строк сетки, с которых начинается новый лист Excel (у OCR пусто): граница листа — всегда
|
||
граница сегмента раскладки (шаг 2);
|
||
- `warnings` — предупреждения шага (формат — шаг 8).
|
||
|
||
Ячейка `Cell` одна для шапки и таблицы: `text` (как показывает источник), `value` (число или дата, если источник их
|
||
знает; у OCR — `null`), `confidence` 0–100 (у Excel — 100), `ref` — позиция в источнике: OCR — `row:<row_index>`,
|
||
Excel — `<лист>!<A1>`. Уникальность в документе требуется от `ref` строки (шаг 3), не ячейки.
|
||
|
||
- **1. Кто размечает** — остаётся, фиксируется полем `source`
|
||
- scan-gateway: без изменений
|
||
- tabular-parser: без изменений
|
||
- **2. Единица разметки** — файл → список кандидатов; у OCR каждая таблица ИС — кандидат, у Excel — каждый блок формы на листе; шаги 2–8 работают только с кандидатом (Р-1 — форма общего кода)
|
||
- scan-gateway: адаптер «таблица json → кандидат»
|
||
- tabular-parser: адаптер «блок формы на листе → кандидат»
|
||
- **3. Шапка** — у каждого кандидата своя; поля = поля `header` контракта, каждое — `Cell`; разбор текста поля — общий код по `text`: ИНН → только цифры, без двойников; дата → словарь периодов (шаг 2 п. 3), который включает все шаблоны сегодняшнего `dates.py` шлюза; единицы → словарь. Уверенность и сырой текст каждого поля доезжают до контракта (`*_confidence`, `*_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» или по позиции листа, граница листа запоминается в `segment_breaks`; тип из маркера сверяется с `type_hint`, расхождение — отдельный кандидат + предупреждение `error`. Лист без маркера и без шапки присоединяется, только если его коды в диапазоне открытой формы (как `_weak_blocker` сейчас) и, при трёх и более кодах, число колонок сумм совпадает с формой; иначе — отдельный кандидат с предупреждением `error`. Раскладка у каждого сегмента своя (шаг 2 п. 2), поэтому разная ширина колонок или объединённые ячейки на листе-продолжении без шапки не сдвигают значения
|
||
- 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` (сетка, `segment_breaks`, `type_hint`).
|
||
|
||
**Единый выход:** `TableLayout` — сегменты сетки (участки между повторными шапками и границами листов), у каждого:
|
||
столбец кода, столбец наименования, колонки значений `[{col, label, label_confidence, period_end, period_kind}]`,
|
||
сопоставленные с первым сегментом по порядку; роль каждой строки сетки (`title` | `header` | `numbering` | `data`).
|
||
|
||
**Диапазоны кодов** (словарь, границы уточняются в спеке): ББ 1100–1799, ОФР 2100–2999, отчёт об изменениях
|
||
капитала 3100–3699, отчёт о движении денежных средств 4100–4499, отчёт о целевом использовании 6100–6499,
|
||
пояснения 5000–5999. «Кодовые диапазоны» для поиска столбца — объединение этих отрезков (годы 2000–2099 и
|
||
четырёхзначные суммы на 1–6 вне отрезков кодами не считаются). «Диапазон столбца» — отрезок, в который попадает
|
||
большинство кодов найденного столбца; им проверяются разлепление строк (шаг 3), слабые продолжения и наследование
|
||
столбца в сегментах.
|
||
|
||
- **1. Столбец кодов** — одно правило: код — текст без пробелов, переносов и краевой пунктуации («1220 ,» → 1220), после замены двойников (порядок: убрать пробелы и переносы → двойники → проверка; «|» → 1 только если результат попадает в кодовый диапазон: «| 140» → 1140), 4–5 цифр (допускается `.0`), первые четыре в кодовых диапазонах. Многострочная ячейка — код, если каждая её часть код. (а) Столбец, над данными которого стоит подпись «Код» / «Коды» / «Код строки» (словарь подписей) **и под которой доля кодов среди непустых ячеек ≥ 0,6 или кодов ≥ 3**; несколько кандидатов — столбец с наибольшим числом кодов; берётся последняя такая подпись над первой строкой с кодом. Защита обязательна: в выгрузках 1С под подписью «Коды» титульного блока стоят ОКУД, дата, ОКПО, ИНН, ОКОПФ (12267 у АО, 12300 у ООО — по первым четырём цифрам это «коды баланса»), ОКЕИ: один код из девяти, доля 0,11. Документ из двух строк остаётся документом: два из двух, доля 1,0. (б) Подписи нет → столбец с наибольшим числом ячеек-кодов, кодов ≥ 3, доля ≥ 0,6 (так парсер сейчас); нет — «колонка кодов не найдена». Статусы строк с нечитаемым или пустым кодом — новое поведение для обоих: шлюз сегодня такие строки пропускает со счётчиком
|
||
- scan-gateway: столбец по подписи получает защиту по доле; запасной подсчёт — по кодовым диапазонам, как сейчас; классификация строк `int(text)` → статусы
|
||
- tabular-parser: добавляет проход по подписи до подсчёта; диапазон формы → кодовые диапазоны
|
||
- **2. Повторная шапка и границы листов** — одно правило: новый сегмент открывает строка с признаком шапки («Код» / та же сигнатура) **или** граница листа из `segment_breaks`; у сегмента своя раскладка: по его шапке, а без шапки — столбец кодов по правилу п. 1, и если сегмент не первый и кодов в нём меньше трёх, ослабленный поиск: столбец с наибольшим числом кодов из диапазона столбца первого сегмента, минимум один (так `relaxed` + `_weak_blocker` парсера сейчас: лист «_2» НПЗ с единственной строкой 2400, у которого столбец кодов в другой колонке, чем на листе 1); колонки значений без шапки — по числам в строках с кодами, сопоставление с первым сегментом по порядку; число не совпало — предупреждение `error`, значения по позиции; столбец кодов не найден ни так, ни так — предупреждение `error` сегмента, строки сегмента не теряются (роль `data`, статусы по шагу 3)
|
||
- scan-gateway: как сейчас, через общее правило
|
||
- tabular-parser: бывший «лист-продолжение со своей раскладкой» становится сегментом; бывшая проверка числа колонок в `join_blocker` — предупреждение сегмента
|
||
- **3. Дата колонки** — один словарь периодов (`statement_periods.yaml`), **объединяющий** сегодняшний yaml парсера и шаблоны `dates.py` шлюза: «На 31 марта 2025 г.», «За январь – март 2025 г.», «31.12.2024», «30/09/25», «30092025», «За 9 месяцев 2025 г.», «за I квартал 2025 г.», «1 полугодие 2026», «с 01.01.2025 по 31.03.2025», «2024», двузначный год, хвост «0:00:00», клетки «2|0|2|4|»; строки `test_dates.py::test_parses_real_forms` шлюза — обязательные тесты словаря. Результат — `period_end` и `period_kind`: абсолютные подписи → `absolute`; относительные («На отчётную дату», «На 31 декабря предыдущего года», «На 31 декабря года, предшествующего предыдущему», «За отчётный период», «За аналогичный период прошлого года») → `relative:<вид>`, дата считается от **эффективной даты документа**: дата отчёта из шапки, иначе самая поздняя абсолютная дата колонок; нет ни той ни другой → `period_end = null`, `period_kind` остаётся относительным, ядро досчитает после правки даты отчёта; подписи нет → `period_kind = none`. Подпись OCR с уверенностью ниже 100, разобранная в дату, подтверждается, если дата равна одной из ожидаемых (эффективная дата, минус год, минус два года) — иначе `error` «дата колонки неуверенна». Перед разбором — очистка двойников OCR. Относительные подписи — Р-6: это бланки по приказу 66н без впечатанных дат, у шлюза сейчас «колонка 0 без даты: На отчетную дату»
|
||
- scan-gateway: `parse_date` → словарь, его шаблоны переезжают в словарь
|
||
- tabular-parser: добавляет очистку двойников (no-op), относительные подписи и шаблоны шлюза
|
||
- **4. Строка заголовка и её отсутствие** — одно правило (обе ветки парсера сохраняются): строка заголовка — последняя над первой строкой с кодом, где в столбце кодов стоит подпись «Код»; иначе последняя над первой строкой с кодом с двумя и более подписями-периодами правее кодов (или одной, не голым годом); двухстрочный заголовок склеивается. Роль `title` — только строки выше найденной строки заголовка (титульный блок «Дата 30 | 9 | 2025» Межениновской); в сегменте без заголовка роли `title` нет, строки выше первого кода с содержимым идут в данные (шаг 3). Заголовка нет ни у одного сегмента документа → колонки значений первого сегмента = столбцы правее кода, где в строках с кодами стоят числа; `label` пустая, `period_end` `null`, `period_kind = none`; одно предупреждение `error` «строка заголовка не найдена». Заголовок есть хотя бы у одного сегмента — остальные сегменты берут его колонки (п. 2) без предупреждения: листы 2–5 ББ НПЗ без шапки остаются `done`
|
||
- scan-gateway: синтез шапки уходит (он эквивалентен); добавляет ветку «заголовок по периодам»
|
||
- tabular-parser: добавляет предупреждение для документа без единого заголовка
|
||
- **5. Строка нумерации «1 2 3 4 5»** — одно правило `is_numbering_row` → роль `numbering`, в данные не попадает
|
||
- scan-gateway: добавляет (AURORA-1517)
|
||
- tabular-parser: как сейчас
|
||
- **6. Подпись без периода** — Р-6: подпись — период по словарю (абсолютный или относительный, п. 3) → колонка с датой; подпись из словаря исключений («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») → пропуск с предупреждением `info`; иначе, если в столбце есть числа в строках с кодами (OCR-мусор вроде «| | г» с уверенностью 17 в aep_3, обрезанная или самодельная подпись) — колонка остаётся с подписью как есть, `period_end = null`, `period_kind = none`, предупреждение `error` с привязкой к колонке, документ `needs_check`; аналитик ставит дату колонке через PATCH или подтверждает после сверки с источником; иначе пропуск. Ядру колонка без даты не мешает для периода формы (эффективная дата, п. 3), но межформенная проверка и гудвилл «на отчётную дату» по такой колонке не считаются, пока дата не проставлена. Пропуск числовой колонки с нераспознанной подписью отвергнут: для OCR это тихая потеря периода с возможным автоподтверждением
|
||
- scan-gateway: добавляет словарь исключений и относительные периоды
|
||
- tabular-parser: перестаёт молча пропускать неизвестные числовые колонки; добавляет относительные периоды
|
||
- **7. Столбец наименования** — одно правило: подпись «Наименование (показателя)» в строке заголовка → этот столбец; иначе столбец левее кодов с наибольшим числом текстов, кроме столбца «Пояснения»; наименование строки — ячейка этого столбца, пусто → ближайшая текстовая ячейка левее кода, кроме «Пояснения»
|
||
- scan-gateway: «наименование», иначе слева от кода → общее правило
|
||
- tabular-parser: `_name_column` / `_line_name` → общее правило
|
||
|
||
### Шаг 3 — Отбор строк данных
|
||
|
||
**Вход:** `FormCandidate` + `TableLayout`.
|
||
|
||
**Единый выход:** `RawLine[]` — по строке на каждую строку сетки с ролью `data`, у которой есть содержимое: код,
|
||
число или непустой текст в колонке значений с уверенностью ниже 100 (признак потерянной суммы: aep_3 row 8 «[190 :»
|
||
с «Ё», «" :»). У строки: `row_index` (порядковый номер в документе), `ref` (уникален в документе; у разлепленных строк
|
||
`row:<i>#<k>`), ячейка кода, `code_status`, ячейка наименования, ячейки значений по колонкам раскладки сегмента,
|
||
`parent_ref` (для `detail`). Статусы (`ok` | `unreadable` | `missing` | `detail`):
|
||
|
||
- `ok` — код разобран;
|
||
- `unreadable` — ячейка кода содержит хотя бы одну цифру, но кодом не разобралась («ИЧ,250», «изо :», «[190 :»);
|
||
ячейка кода без единой цифры («}», «:») считается пустой;
|
||
- `detail` — Р-8: строка без кода с содержимым, стоящая под строкой с кодом (в том же сегменте или в предыдущих
|
||
сегментах документа — родитель продолжается через разрыв страницы), — расшифровка («в том числе: …», «из них …»,
|
||
«Оборудование к установке», «Товар (41.01)»); родитель — ближайшая строка с кодом выше. Сервисы её отдают, ядро
|
||
хранит, в проверки §7, агрегаты и критерий `done` она не входит, экран по умолчанию не показывает. Отменяет OQ-36 /
|
||
OQ-141 («строки без кода не отдаются»); OQ-149 («восстановление строк по наименованию») остаётся открытым;
|
||
- `missing` — строка без кода с содержимым, над которой во всём документе нет строки с кодом (кода не было или он
|
||
потерян в самом начале таблицы: «Выручка» aep_3, где 2110 уехал в ячейку значения); строки роли `title` сюда не
|
||
попадают.
|
||
|
||
Строка без кода и без содержимого (заголовок раздела «АКТИВ», «в том числе:» без сумм) — не строка. Счётчиков
|
||
отброшенных нет — есть статусы. Повтор кода — не статус: по правилам отчётности один код может стоять у нескольких
|
||
строк с разными наименованиями, все они `ok`. Строка формы с пустой ячейкой кода под другой строкой с кодом попадёт в
|
||
`detail` и будет скрыта; её значения не войдут в сумму раздела, нарушение §7 подсветит родителя (цель соотношения
|
||
отсутствует при непустых членах — нарушение, шаг 7), аналитик раскроет расшифровки и назначит код (Р-13) — осознанная
|
||
цена Р-8.
|
||
|
||
- **1. Код-мусор** — статус `unreadable`, строка сохраняется с сырым текстом кода (AURORA-1462: «ИЧ,250» видна, аналитик назначает 1250). Разбор кода — по правилу шага 2 п. 1
|
||
- scan-gateway: счётчик → статус
|
||
- tabular-parser: отличает «нет кода» от «не разобран»
|
||
- **2. Склейка двух строк в одну** — одно правило, выполняется **до** разбора кода: ячейка кода содержит N частей через перевод строки и ячейки значений — N частей → N строк (OQ-156) с `ref` `row:<i>#<k>`; разлепление делается, если хотя бы одна часть кода разобралась как код диапазона столбца («44 30\nI |» aep_6 не множится: 4430 не в диапазоне ББ), нечитаемая часть становится строкой `unreadable`; число частей не совпало или ни одна часть не код — одна строка, многострочное значение → `amount = null`, `string_value` = текст, `is_recognized = false`. Значения разлепленных строк получают `is_recognized = false` и нуждаются в покрытии проверками §7 (Р-17: сошлись — подсветки нет, иначе `needs_review`); у Excel не срабатывает
|
||
- scan-gateway: как сейчас, через общее правило; разлепление при нечитаемой части — новое; статус документа после разлепления — по общему правилу, а не безусловный `needs_check`
|
||
- tabular-parser: правило общее, no-op
|
||
- **3. Повтор кода** — Р-4: повтор кода — законный случай (один код, разные наименования), не ошибка и не предупреждение. Все строки сохраняются как есть со статусом `ok`; текстовое предупреждение и `checks.duplicate_line_codes` уходят. Для проверок §7, подтверждения и всего, что идёт после, значения одного кода идут суммой (см. «Что уходит из ядра»; договорённость ДБД в AURORA-1431; закрывает AURORA-1406). Неверно прочитанный код, случайно совпавший с существующим, ловит структурная проверка кода: повтор, у которого хотя бы одна копия с уверенностью ниже 100, — `needs_review` у этой строки (Р-16)
|
||
- scan-gateway: «оставлена первая» → все строки
|
||
- tabular-parser: `duplicates` уходит
|
||
- **4. Отброшенная строка** — строки не отбрасываются: `unreadable` / `missing` / `detail` идут дальше со статусом, `row_index` и `ref` (AURORA-1634, 1462); ядро не достраивает вместо них пустую шаблонную строку молча; подтвердить документ, пока у `unreadable` / `missing` есть суммы, нельзя (Р-13). Отмена OQ-36 / OQ-141 — Р-8
|
||
- scan-gateway: счётчики → статусы
|
||
- tabular-parser: счётчики → статусы; строки без кода с суммами начинают отдаваться
|
||
|
||
### Шаг 4 — Значения ячеек
|
||
|
||
**Вход:** ячейки значений `RawLine` по колонкам раскладки (`Cell` или `null`).
|
||
|
||
**Единый выход:** `StatementValue {column_index, amount, string_value, text, confidence, is_recognized}`: `text` —
|
||
сырой текст источника всегда, как `Cell.text`; `string_value` — как сейчас, только когда сумма не разобрана
|
||
(`amount = null`, непустой текст); `needs_review` досчитывается в шаге 8.
|
||
|
||
- **1. Предобработка текста** — у ячейки после шага 1 либо есть `value`, либо только `text`. `value` — число → `amount = value`, квантование до 2 знаков half-up, не больше 16 целых цифр (`numeric(18,2)`), иначе `amount = null`, `string_value` = текст, `is_recognized = false` и предупреждение; `value` — дата или логическое → `amount = null`, `string_value` = текст, `is_recognized = false`; `value` нет → один `parse_amount(text, source)` для обоих: двойники (Р-11), пробелы и тонкие пробелы, минус «−» (U+2212) как знак, скобки → минус, причём `}` и `]` принимаются как закрывающая скобка (OQ-147), прочерк → `null`; разделители: у `ocr` запятая — тысячи при группе из трёх цифр (AURORA-1405, sg#35) и точка — тысячи при группах из трёх цифр («837.994», sg#37), текст с тремя и более знаками после разделителя — `amount = null`, `string_value` = текст (решение № 30 среза 3a сохраняется); у `excel` запятая и точка — десятичный разделитель («4,200» = 4,20, «1.500» = 1,5, как `to_money` сейчас); «лучшая догадка» из цифр ограничена 16 цифрами, иначе `null`; многострочное значение в строке с однострочным кодом → `amount = null`, `string_value` = текст, `is_recognized = false`; Excel-ячейки «число текстом» идут тем же путём. Разбор ОСВ (`to_money` в `classify.py`) общим `parse_amount` не заменяется — он остаётся за пределами этой работы
|
||
- scan-gateway: `parse_amount` переезжает в общий код и получает квантование, предел длины, `}` / `]`, «−», тонкие пробелы
|
||
- tabular-parser: `to_money` для форм заменяется общим `parse_amount` с `source = excel`
|
||
- **2. Смысл `is_recognized`** — один: текст разобран как число без выброшенных и исправленных символов (замена двойника — исправление; `}` / `]` вместо скобки — тоже); `confidence` — отдельно, от источника; оба признака сервис сводит в `needs_review` (шаг 8, «Подтверждение проверкой»), экран подсвечивает по нему
|
||
- scan-gateway: отвязывает от `confidence`
|
||
- tabular-parser: как сейчас
|
||
- **3. «42831 1»** — в `parse_amount` пробел допустим только как разделитель тысяч (группы ровно по три цифры); иначе `amount` — лучшая догадка (цифры подряд), `is_recognized = false`, сырой текст — в `text` (AURORA-1511, 1519)
|
||
- scan-gateway: меняется через общий код
|
||
- tabular-parser: то же правило, у Excel почти не срабатывает
|
||
- **4. Единицы** — одно правило: единицы «тыс. руб.» и дробных ≥ 10 % → предупреждение документа `info` (как у парсера сейчас, документ остаётся `done`); то же сочетание у книги xlsb без числовых форматов — `error`: вероятен масштаб ×1000 (рубли с копейками, которые формат переводил в тысячи), а §7 сходится при любом масштабе
|
||
- scan-gateway: добавляет
|
||
- tabular-parser: как сейчас; xlsb-сочетание — новое
|
||
- **5. Пустая ячейка** — Р-2: позиции нет, текст пуст или прочерк → `amount = null`, `string_value = null`, `is_recognized = true`, `confidence` ячейки (нет ячейки — 100), собственной подсветки нет. По `fixtures/ocr` ИС отдаёт ячейку на каждую позицию каждой строки: пустые — пустой текст с уверенностью 100; непрочитанные — пустой текст с уверенностью 0, и такие нуждаются в покрытии по общему правилу шага 8 (снимается покрытием или подтверждением ячейки аналитиком); позиция отсутствует только при несовпадении колонок сегмента, что покрыто предупреждением `error` шага 2 п. 2
|
||
- scan-gateway: «ячейки нет → 0, не распознано» уходит
|
||
- tabular-parser: как сейчас
|
||
|
||
### Шаг 5 — Код, наименование, редакция
|
||
|
||
**Вход:** `RawLine` (статус, `row_index`, `ref`, ячейки кода и наименования) + `StatementValue[]`.
|
||
|
||
**Единый выход:** `StatementLine {row_index, ref, line_status, line_code, line_code_text, line_code_confidence,
|
||
line_name, line_name_confidence, parent_ref, values}`: `line_code` — разобранный код (4–5 цифр) или `null` при
|
||
`unreadable` / `missing` / `detail`; `line_code_text` — сырой текст ячейки кода; `line_code_confidence` — от источника,
|
||
а у кода с заменой двойника — ниже 100 (неуверенный); `line_status` = `code_status` шага 3; `parent_ref` — у `detail`.
|
||
Несколько строк с одним кодом — обычные строки `ok`, порядок как в файле.
|
||
|
||
- **1. Источник наименования** — Р-3: сервисы отдают распознанный текст наименования как есть, с уверенностью источника; соответствие «код → наименование» — только в ядре и только когда нужно (показ справочного имени, достройка шаблонных строк, сверка для неуверенных кодов — Р-16). Экран показывает справочное имя, файловое — рядом (AURORA-1406, 1522)
|
||
- scan-gateway: имя из `statement_lines.yaml` → имя из OCR
|
||
- tabular-parser: как сейчас
|
||
- **2. Редакция (66н / ФСБУ 4)** — в сервисах редакция нужна только для упорядоченного перечня кодов (подтверждение кода структурой, шаг 8) и выбирается по эффективной дате документа (шаг 2 п. 3), без даты — проверяются обе редакции. Наименования и достройка шаблонных строк — ядро по его словарю имён; проверка «код не из редакции формы» — ядро по своей копии перечня общего кода
|
||
- scan-gateway: `_choose_edition` → выбор редакции перечня кодов в общем коде
|
||
- tabular-parser: добавляет выбор редакции перечня кодов
|
||
- **3. Справочник строк** — только в ядре; в общий код сервисов не входит; копия в шлюзе удаляется
|
||
- scan-gateway: удаляет `api/rules/statement_lines.yaml`
|
||
- tabular-parser: без изменений
|
||
- **4. Уверенность кода** — от источника (ИС / 100), без срезания до 99 по справочнику; «код не в справочнике» — проверка ядра, предупреждение `info` к строке, не сервисов
|
||
- scan-gateway: убирает `min(conf, 99)`
|
||
- tabular-parser: как сейчас
|
||
|
||
### Шаг 6 — Сборка документа
|
||
|
||
**Вход:** `FormCandidate` (`type_hint`, `evidence`, `header`) + `TableLayout` + `StatementLine[]`.
|
||
|
||
**Единый выход:** `StatementDocument {document_type, form_variant, locator, header, columns, lines}` без `checks`.
|
||
|
||
- **1. Тип формы** — одно правило: большинство кодов в диапазоне ББ → `BALANCE`, ОФР → `INCOME`, прочих форм и пояснений → `DISCLOSURE`; совпало с `type_hint` или `type_hint` `null` — тип по кодам; противоречит — тип по кодам + предупреждение `error`; кодов нет — `type_hint`. Без колонки кодов: `type_hint` есть → документ без строк (`DISCLOSURE` — `done`, иначе `needs_check` с предупреждением `error`); `type_hint` нет: у `ocr` каждая таблица ИС с содержимым — потерянный документ, предупреждение `error` файла (сегодня шлюз: «нет шапки и столбца кода» → `needs_check`), у `excel` лист «прочее» — `info`. `DISCLOSURE` по `type_hint` — шаги 2–7 не выполняются; `DISCLOSURE` по кодам — шаги 2–5 выполнены, строки и колонки отбрасываются (ядро строки пояснений не хранит), проверки не считаются, документ `done`. **Вариант формы** `form_variant` — `full` | `simplified`, только по структуре кодов (ОКУД для этого не годится: 0710096 — код КНД комплекта упрощённой отчётности, на бланках стоят 0710001 / 0710002): ББ — есть 1600 и нет ни 1100, ни 1200; ОФР — есть 2400 или 2110 и нет ни 2100, ни 2200, ни 2300; шаг 7 и перечень кодов берут упрощённый набор. Образец упрощённой формы — в fixtures при первой возможности
|
||
- scan-gateway: как сейчас, через общее правило; добавляет упрощённый вариант и `error` за потерянную таблицу
|
||
- tabular-parser: добавляет сверку с кодами и упрощённый вариант
|
||
- **2. Склейка страниц** — решена в шаге 1, различия нет
|
||
- scan-gateway: без изменений
|
||
- tabular-parser: без изменений
|
||
- **3. `locator`** — одна грамматика «пары `ключ:значение` через `;`», набор ключей по источнику: `file:<имя json>;table:<i>` / `sheet:<A>[,<B>]` — ключи как сейчас, golden-эталоны и §11 не меняются
|
||
- scan-gateway: без изменений
|
||
- tabular-parser: без изменений
|
||
|
||
Шапка документа — поля `header` кандидата как есть (`text` → `*_text`, `value` → поле, `confidence` → `*_confidence`
|
||
у всех полей).
|
||
|
||
### Шаг 7 — Самопроверки
|
||
|
||
**Вход:** `StatementDocument`.
|
||
|
||
**Единый выход:** `StatementChecks` у обоих: `relations_failed[]`, `soft_relations_failed[]` (каждое нарушение — с
|
||
кодами слагаемых и `column_index`, чтобы экран подсветил ячейки). Правила счёта:
|
||
|
||
- значение кода в колонке — сумма по всем строкам `ok` с этим кодом (повтор кода — законный случай, шаг 3 п. 3); строки
|
||
`detail`, `unreadable`, `missing` в соотношения не входят; `duplicate_line_codes` уходит; нарушение подсвечивает все
|
||
строки кода;
|
||
- соотношение считается **посчитанным**, если непуста цель или хотя бы один член; пропускается, только когда пусто
|
||
всё (ни строки `ok` с кодом цели, ни одного непустого члена). Пустая цель при непустых членах — жёсткое нарушение
|
||
(«БАЛАНС» без кода, ушедший в `detail`, делает 1600 невидимым, и это должно подсветить родителя); непустая цель при
|
||
пустых членах — тоже нарушение (лист только с активом: «1600 = 1700» при отсутствующем 1700 — `needs_check`, как
|
||
сегодня у трёх файлов манифеста парсера). Сегодня оба сервиса и ядро соотношение с отсутствующей целью пропускают
|
||
— поведение меняется; посчитанное соотношение из всех `null` ничего не подтверждает (то же правило, что для
|
||
«родитель = Σ подстрок» по AURORA-1527);
|
||
- «сошлось» / «не сошлось»: жёсткое соотношение не сошлось, если расхождение больше допуска ±1 **и** больше
|
||
округления 0,001 % — только тогда оно в `relations_failed`; расхождение в пределах округления и мягкие соотношения —
|
||
в `soft_relations_failed`, это `info`, на статус и подсветку не влияют (OQ-138 сохраняется);
|
||
- соотношение с членами по модулю (`abs_terms`: 2120, 2210, 2220, 2330, 2350, 2410) сходится при любом знаке такого
|
||
члена и потому **подтверждает только величину, не знак** — см. «Подтверждение проверкой»;
|
||
- набор соотношений выбирается по `form_variant`: полный или упрощённый (нет подытогов 1100 / 1200 / 1300 у ББ,
|
||
2100 / 2200 / 2300 у ОФР, свои равенства), оба — в одном словаре `statement_checks.yaml`;
|
||
- родитель пятизначной подстроки — ближайшая четырёхзначная строка выше с теми же первыми тремя цифрами, иначе код
|
||
из первых четырёх цифр (`subline_parents`, §12 п. 7: 12310 под 1230).
|
||
|
||
Проверки по словарю имён (код не в словаре) и по перечню кодов (код не из редакции формы по эффективной дате) — в
|
||
ядре, как предупреждения `info` к строке; сервисы словаря имён не знают (Р-3, Р-12).
|
||
|
||
- **1. Проверки только у парсера; словарь §7 в двух копиях; ядро результат не читает** — проверки — общий код с одним словарём `statement_checks.yaml` у обоих сервисов; для OCR нарушение §7 — главный признак неверно прочитанной цифры; ядро читает `checks` из результата, хранит их (`document_issue`, см. «Что уходит из ядра») и пересчитывает тем же кодом только после правок аналитика (PATCH)
|
||
- scan-gateway: добавляет шаг
|
||
- tabular-parser: `run_statement_checks` → общий код
|
||
|
||
### Шаг 8 — Статус и предупреждения
|
||
|
||
**Вход:** `StatementDocument` + `StatementChecks` + предупреждения шагов 1–7.
|
||
|
||
**Единый выход:** у каждого документа `status` (`done` | `needs_check`), `warnings[]`, `needs_review` у каждого
|
||
значения, каждой строки и каждого поля шапки; у файла `metrics.warnings[]`; статус конверта — агрегат.
|
||
Предупреждение — структура `{code, severity: error | info, message, line_refs?: [], column_index?, field?}`, а не
|
||
строка; `line_refs` — список, потому что нарушение подсвечивает все строки кода.
|
||
|
||
- **1. Критерий `done`** — одна функция общего кода с входами «раскладка, шапка, статусы строк, `needs_review`, нарушения, предупреждения, подтверждения и акцепт аналитика»: раскладка найдена ∧ (организация или ИНН) ∧ период определён (дата отчёта **или** самая поздняя абсолютная дата колонок — `statement_period` ядра; у ИС поле `date` часто мусор: у aep_6 и «БО 1 кв 2025» там название организации) ∧ все строки `ok` или `detail` ∧ нет значений, строк и полей шапки с `needs_review` (см. «Подтверждение проверкой» ниже) ∧ нет жёстких нарушений §7 вне допуска и округления ∧ нет предупреждений уровня `error`. `DISCLOSURE` — `done` без условий о раскладке, строках и проверках (как сейчас). Excel выполняет условия уверенности автоматически; структурная проверка кодов к Excel не применяется (Р-12). Квартал периода, статус конверта, сверка ИНН и наименований — знание ядра, в `done` не входят
|
||
- scan-gateway: «ничего не выброшено и уверенность 100» → общий критерий
|
||
- tabular-parser: `_is_ok` → общий критерий; добавляются строки и значения
|
||
- **2. Влияние предупреждений** — по уровню: `error` → `needs_check`, `info` — не влияет; у обоих одинаково. Таблица уровней, полная версия — в спеке. `error`: ошибочный json набора при успешном соседе (уровень файла; решение № 28 среза 3a), `BACKEND_PARSING_ERROR` таблицы, таблица ИС с содержимым без колонки кодов и без типа (уровень файла), сегмент с содержимым без колонки кодов (в том числе потерянный лист-продолжение), лист с тремя и более кодами и другим числом колонок, не присоединившийся к форме, колонки сегмента не совпали, колонка без даты, дата колонки неуверенна, заголовка нет ни у одного сегмента, тип по кодам противоречит заголовку, тип маркера продолжения противоречит форме, ни организации, ни ИНН в шапке, период не определён (нет ни даты отчёта, ни дат колонок), дата отчёта неуверенна и не совпала с датами колонок, xlsb без форматов при «тыс. руб.» и дробных. `info`: ИНН не найден или неуверен при найденной организации (сверку с ЮЛ делает ядро — `MISMATCH`; НПЗ ОФР без ИНН остаётся `done`), организация не найдена или неуверенна при найденном ИНН, дата отчёта не разобрана при наличии дат колонок, единицы не найдены (аналитик правит в шапке), формат файла не совпал с `file_type` (файл прочитан верно), подпись из словаря исключений пропущена, дробные при «тыс. руб.» (кроме xlsb без форматов), xlsb без числовых форматов само по себе (tp#42, документ остаётся `done`), лист Excel «прочее» без `type_hint` и кодов, разлеплена склеенная строка, код не в словаре имён или не из редакции (ядро). Принцип: `error` — только то, что меняет данные или их полноту
|
||
- scan-gateway: «любое → `needs_check`» уходит
|
||
- tabular-parser: предупреждения начинают влиять
|
||
- **3. Статус документа** — в контракте: `document.status` у каждого документа, включая ОСВ (у ОСВ `done`, когда ведомость разобрана, как сейчас; её предупреждения — `info`); конверт `done` ⇔ все документы `done` ∧ нет предупреждений файла уровня `error`; иначе `needs_check`; документов нет — `error` с кодом по источнику: `RECOGNITION_FAILED` у шлюза, `INVALID_DOCUMENT` у парсера — это различие остаётся, коды описывают разные причины. Статус конверта в условие `RECOGNIZED` ядра не входит, туда входят предупреждения `error` уровня файла (Р-19)
|
||
- scan-gateway: добавляет
|
||
- tabular-parser: внутренний `ok` → в контракт
|
||
- **4. Формат предупреждений** — структура (см. выше) вместо строк `<json>: table:<i>: …` / `sheet=<имя> …` у всех документов результата, включая ОСВ; место — в `locator` документа, а не в тексте
|
||
- scan-gateway: меняет
|
||
- tabular-parser: меняет, включая путь ОСВ
|
||
- **5. Привязка** — к документу, строкам (`line_refs`), ячейкам (`line_refs` + `column_index`) или полю шапки (`field`); `ref` строки уникален в документе и хранится в ядре вместе с `row_index`, поэтому экран показывает предупреждение у места и после сохранения
|
||
- scan-gateway: меняет
|
||
- tabular-parser: меняет
|
||
|
||
**Подтверждение проверкой.** Проверка §7 — арбитр, уверенность — подсказка там, где проверки нет. По каждому
|
||
значению сервис считает `needs_review`:
|
||
|
||
- значение **нуждается в покрытии**, если `confidence` ниже порога (Р-5: 100) или `is_recognized = false` (число
|
||
собрано из грязного текста, с заменой двойников или из разлепленной строки — Р-7, Р-11, Р-17); чистые значения
|
||
(уверенность 100 и `is_recognized = true`) в покрытии не нуждаются: правило покрытия и правило знака к ним не
|
||
применяются;
|
||
- значение **покрыто**, если в его колонке есть жёсткое соотношение с участием его кода, которое посчитано и сошлось
|
||
(в пределах допуска или округления), и `amount` самого значения не `null` (мусор, давший `null`, покрытым не
|
||
бывает);
|
||
- **знак**: для члена соотношения с `abs_terms` покрытие подтверждает только величину. Знак подтверждён, если форма
|
||
записи знака у ячейки (скобки, минус, без знака — по `text`) совпадает с конвенцией колонки — формой записи у
|
||
большинства членов `abs_terms` этой колонки с непустым `amount`, `is_recognized = true` и без правки аналитика
|
||
(правленые ячейки в голосование не входят); не совпадает или сравнивать не с чем — `needs_review = true` независимо
|
||
от соотношения. Так «(7744536}» с `}` как закрывающей скобкой даёт −7 744 536 и подтверждается, если 2210 и 2220 в
|
||
той же колонке тоже в скобках; «{38378)» при остальных в скобках — подсвечивается;
|
||
- `needs_review = true`, если значение нуждается в покрытии и не покрыто (или не подтверждён знак);
|
||
- `true` у всех ячеек жёсткого соотношения в колонке, где оно не сошлось вне допуска и округления, независимо от
|
||
уверенности и чистоты; мягкие соотношения и расхождения в пределах округления подсветки не дают;
|
||
- иначе `false`: значение подтверждено проверкой, не подсвечивается и статус не трогает; сырой текст (`text`) и
|
||
уверенность остаются в подсказке ячейки.
|
||
|
||
Экран подсвечивает ячейки только по `needs_review`. Ядро хранит `needs_review` и `text` как пришли и пересчитывает
|
||
`needs_review` вместе с `checks` общим кодом после PATCH (конвенция знака считается от сохранённого `text`), добавляя
|
||
своё: правленое значение (`is_modified`) или подтверждённое аналитиком без изменения (`is_confirmed`, хранится) —
|
||
`needs_review = false`; акцепт нарушений — жёсткие нарушения не считаются; предупреждения `error` документа
|
||
сохраняются и после правки, кроме снимаемых соответствующей правкой (таблица — в «Что уходит из ядра»). Не покрыто
|
||
проверками и остаётся на уверенности: справочные строки ОФР 25xx / 29xx, детализация без проверки родителя,
|
||
`DISCLOSURE`, колонки, где есть только мягкие соотношения. Риск — две взаимно компенсирующие ошибки в одном
|
||
соотношении; принимается (перекрёстные соотношения баланса его снижают). Закрывает AURORA-1524 без ущерба для 1519.
|
||
Сошедшееся посчитанное равенство подтверждает и догадку из грязного текста (Р-7): случайное совпадение требует второй,
|
||
компенсирующей ошибки в том же равенстве.
|
||
|
||
**Поля шапки.** У каждого поля шапки свой `needs_review`: дата отчёта с уверенностью ниже 100 подтверждена, если
|
||
совпала с самой поздней абсолютной датой колонок, иначе `needs_review = true` и предупреждение `error` «дата отчёта
|
||
неуверенна» — иначе неверно прочитанный год давал бы неверный период и автоподтверждение; ИНН и организация с
|
||
уверенностью ниже 100 — `info`, их сверяет ядро (ИНН — `MISMATCH` с ЮЛ, организация — по алиасам); единицы и прочие
|
||
поля на статус не влияют. Сегодня шлюз требует уверенность 100 у ИНН и организации и распознанную дату — правило
|
||
становится точнее, не слабее.
|
||
|
||
**Код строки (Р-5, Р-12, Р-16).** Уверенность кода подтверждается не §7 (перепутанные соседи 1220 и 1230 дают ту же
|
||
сумму раздела), а структурой и наименованием; правило применяется **только к неуверенным кодам** — уверенность ниже
|
||
100 или замена двойника (Р-12): у Excel и чистых OCR-кодов расхождение с перечнем даёт предупреждение `info` ядра «код
|
||
не из редакции», а не `needs_check` — иначе пятизначные подстроки 1С (11101, 23201) и нестандартные строки клиента
|
||
(1231) блокировали бы автоподтверждение. В общем коде лежит **упорядоченный перечень кодов** каждой формы по
|
||
редакциям (66н, ФСБУ 4) и вариантам (полная, упрощённая) без наименований — порядок бланка, где итоги стоят после
|
||
слагаемых (1110 … 1190, 1100; 2110, 2120, 2100), а не по возрастанию. Источник перечня — объединение кодов шаблонов
|
||
банка (словарь ядра) и кодов печатных форм 1С по редакции: 1105, 1530, 2420, 2460, 2930 и другие, которых в словаре
|
||
ядра нет. Алгоритм: четырёхзначные коды строк документа образуют последовательность; для каждой проверяемой
|
||
редакции берётся наибольшая подпоследовательность, согласованная с порядком бланка; редакция — та, где она длиннее
|
||
(при равенстве — по эффективной дате); если таких подпоследовательностей несколько, подтверждены только коды, общие
|
||
для всех; коды с уверенностью 100 участвуют в последовательности как подтверждённые якоря. Пятизначный код
|
||
подтверждён, если подтверждён его родитель (правило шага 7) и подстрока стоит после него. Равные соседние коды
|
||
допустимы, но повтор кода, у которого хотя бы одна копия неуверенна, — `needs_review` у неуверенной строки (Р-16).
|
||
Неподтверждённый неуверенный код — `needs_review` у строки (`StatementLine.needs_review`), документ — `needs_check`.
|
||
Структура не ловит подмену неуверенного кода на незапечатанного соседа того же раздела (1110 → 1120, когда 1120 на
|
||
бланке пропущен): это закрывает ядро — для строк с неуверенным кодом условие `RECOGNIZED` включает совпадение
|
||
распознанного наименования со справочным по коду (Р-16; порог сходства — в спеке), расхождение подсвечивает строку
|
||
через `document_issue`. По `fixtures/ocr` коды часто приходят с уверенностью 50 и 25, поэтому требовать 100, как
|
||
шлюз сейчас, нельзя. Перечень кодов — не словарь строк: наименований в нём нет, Р-3 не нарушается; ядро пользуется
|
||
своей копией перечня для проверки «код не из редакции», словарь ядра хранит только имена. Уверенность наименования
|
||
на статус и подсветку в сервисах не влияет: сервисы отдают текст как есть.
|
||
|
||
### Шаг 9 — Результат
|
||
|
||
Одинаково, как сейчас; контракт `recognition-result.schema.json` → **v2.0** (Р-9: по правилам контракта смена типа
|
||
поля — MAJOR). Что меняется: `line_code` nullable, `line_status`, `line_code_text`, `row_index`, `ref`,
|
||
`parent_ref`, `needs_review` у значения, строки и полей шапки, `values[].text`, `header.*_confidence` и `*_text` у
|
||
всех полей; `document.status` у всех документов, включая ОСВ, `document.form_variant`; `document.warnings[]` и
|
||
`metrics.warnings[]` — структуры с уровнем и привязкой у всех документов, включая ОСВ; `columns[].period_kind` и
|
||
`label_confidence`; `checks` обязателен для `BALANCE` / `INCOME` со строками у обоих конвейеров (у документа без строк
|
||
отсутствует), нарушения с кодами слагаемых, без `duplicate_line_codes`; `service.statements_version` — коммит копии
|
||
общего кода, которым посчитан результат. Выкладка: ядро поднимает `SUPPORTED_MAJOR` и на время перехода принимает
|
||
1.x и 2.0 (два маппера, ack тоже 1.x и 2.0; при приёме 1.x статус документа считается по прежнему правилу D-6 —
|
||
`job_done ∧ проверки ∧ values_ok ∧ …`, новые поля заполняются по умолчанию: `line_status = ok`, `row_index` по
|
||
порядку, `text = string_value`), сервисы переходят на 2.0 после ядра — порядок «сначала ядро» обязателен, иначе
|
||
первый же результат с `null` в `line_code` даёт файлу `ERROR`. Запрос `recognition-request` остаётся 1.x. Путь ОСВ
|
||
в парсере меняется только в части структуры предупреждений и `document.status`.
|
||
|
||
### Что уходит из ядра и что в нём меняется
|
||
|
||
- `build_statement`: повторная дедупликация колонок, кодов и значений (контракт гарантирует уникальность колонок;
|
||
повторы кодов — законные строки, хранятся все как пришли).
|
||
- **DBML v5.19 и одна миграция:** `financial_statement_line` — снять уникальность `(meta_id, line_code)`, добавить
|
||
`row_index` (у строк из источника; у шаблонных и созданных правкой — `null`), `source_ref` (частичный уникальный
|
||
индекс по `(meta_id, source_ref)` там, где не `null`), `line_status`, `line_code_text`, `parent_row_index` (родитель
|
||
`detail`), `needs_review`, `is_confirmed` (подтверждение кода аналитиком); `financial_statement_value` —
|
||
`source_text` (из `values[].text`), `needs_review` (как пришло от сервиса, пересчитывается ядром после PATCH;
|
||
пересматривает решение v5.13 «считается, не хранится»), `is_confirmed` (подтверждено без изменения, хранится вместе
|
||
с `confirmed_by / at`); `financial_statement_column.period_kind`, `label_confidence`; `financial_statement_meta.form_variant`,
|
||
`needs_review` полей шапки; таблица `document_issue` (вид `warning` | `relation` | `soft`, код, уровень, сообщение,
|
||
список `line_id`, `column_id?`, `field?`, источник `service` | `core`) — хранит предупреждения и нарушения как
|
||
пришли; при перезагрузке файла очищается; при пересчёте после PATCH записи `relation` / `soft` заменяются целиком
|
||
независимо от источника, записи `warning` источника `service` сохраняются, кроме снимаемых правкой;
|
||
`recognition_job.statements_version`; `document` — снять уникальность `(stored_file_id, document_type,
|
||
osv_account_code)` только для `BALANCE` / `INCOME` (частичный индекс: ОСВ по счёту и `DISCLOSURE` / `OTHER` остаются
|
||
«один на тип на файл», §9.5 п. 2) и добавить порядковый номер формы в файле (Р-10); в ту же миграцию — `crm_id`
|
||
ГСК / ГСЗ (AURORA-1619, arm-core#112). Заполнение при миграции: `row_index` существующих строк — по порядку кода,
|
||
`line_status = ok`, `needs_review` значений — по прежнему `values_ok`, `source_text = string_value`.
|
||
- Повторы кодов (Р-4): для проверок §7, подтверждения и всего, что идёт после (КФО, выгрузки, сверки), документ
|
||
читается через **одну функцию «агрегат формы по коду»** — сумма значений строк `ok` по колонке, список `line_id`,
|
||
справочное имя — чистой функцией, без хранения (DECISION-5). На неё переводятся все точки, где сегодня «одна
|
||
строка на код»: `_line_amount` (межформенная проверка 1370 / 2400), `statement_amounts`, `subline_parents` /
|
||
`parent_code`, `formLayout` (дети под родителем), `_resolve_cells`, `metrics.fields_*`.
|
||
- `statement_status` (D-6) при приёме 2.0: `RECOGNIZED` ⇔ `document.status = done` ∧ нет предупреждений `error`
|
||
уровня файла (потерянный json или таблица ИС, решение № 28 среза 3a; `needs_check` соседнего документа само по себе
|
||
не блокирует — Р-19) ∧ ИНН ≠ `MISMATCH` ∧ период — конец квартала (`period_known`: иначе документ `RECOGNIZED`, но
|
||
не подтверждаемый, 409 `PERIOD_REQUIRED`) ∧ у строк с неуверенным кодом распознанное наименование совпало со
|
||
справочным (Р-16) ∧ в файле нет второй формы того же типа и периода (Р-18: при двух и более — ни одна не
|
||
автоподтверждается). Остальные признаки D-6 не пересчитываются.
|
||
- §7 при загрузке: `checks`, `needs_review` и предупреждения берутся из результата и хранятся (`document_issue`);
|
||
ядро добавляет только `info` по словарю имён и перечню кодов и свою сверку наименований для неуверенных кодов.
|
||
Пересчёт общим кодом — только после PATCH, с правилами `is_modified` или `is_confirmed` ⇒ `needs_review = false` у
|
||
значения; `line_code_modified` или `is_confirmed` ⇒ `needs_review` строки = false; акцепт ⇒ жёсткие нарушения
|
||
сняты. Версия общего кода из результата (`statements_version`) пишется в `recognition_job`; при пересчёте ядро
|
||
сравнивает её со своей копией, расхождение — предупреждение `info` к документу (на окне выкладки «сначала ядро»
|
||
расхождение штатно).
|
||
- **Какая правка какое `error` снимает:** PATCH `header.report_date` — «период не определён», «дата отчёта не
|
||
разобрана», «дата отчёта неуверенна»; PATCH `header.inn` или `organization_name` — «ни организации, ни ИНН»; PATCH
|
||
`columns[].period_date` — «колонка без даты», «дата колонки неуверенна»; назначение кода или отметка `detail` —
|
||
нарушения, пересчитываемые §7. Остальные `error` сервиса (колонки сегмента не совпали, заголовка нет, тип
|
||
противоречит, потерянный json, лист или таблица, xlsb с масштабом) снимаются только перезагрузкой файла.
|
||
- **PATCH `…/statement`** (Р-13): помимо `header` и `values` принимает `values[].confirmed` (подтвердить ячейку без
|
||
изменения значения: `is_confirmed`, `needs_review = false`, `is_modified` не ставится, аудит), `columns:
|
||
[{column_id, period_date}]` (дата колонке без подписи или с неуверенной подписью; пересчитывает `is_current`;
|
||
`report_date: null` обнуляет `period_end` относительных колонок) и `lines`: «назначить код» (`line_id`, `line_code`),
|
||
«отметить расшифровкой» (`line_id`, `line_status = detail`), «подтвердить код» (`line_id`, `confirmed`). Переходы:
|
||
`unreadable` / `missing` / `detail` → `ok` назначением кода; `unreadable` / `missing` / `ok` → `detail` отметкой;
|
||
`ok` → `ok` с другим кодом (смена кода, прежний в `line_code_original_value`); `ok` неуверенный → подтверждён.
|
||
Правила: код 4–5 цифр, может совпадать с существующим (Р-4); незнакомый словарю — `info`; назначенный или
|
||
подтверждённый код снимает `needs_review` строки, `line_code_confidence` становится 100, `line_code_modified`,
|
||
`line_code_original_value`; у `detail` родитель пересчитывается как ближайшая строка `ok` выше по `row_index`;
|
||
шаблонная строка с таким кодом, если она уже создана правкой аналитика с суммами, — 409 `LINE_EXISTS` с подсказкой
|
||
(аналитик сначала очищает её суммы или выбирает другой код), без сумм — удаляется, её место занимает настоящая;
|
||
пара «`line_status = ok` без кода» — 422; удаления строк нет (первичка не мутируется, `detail` — способ вывести
|
||
строку из сумм). Строки, созданные правкой: `row_index = null`, `source_ref = null`, `line_status = ok`. Аудит
|
||
`RECOGNIZED_DATA_EDIT` с `before / after` по коду, статусу, родителю и подтверждению. Один PATCH — один пересчёт и
|
||
одна запись.
|
||
- **Подтверждение документа** (Р-13, Р-18): порядок проверок 409 по §9.5 п. 4: `PERIOD_REQUIRED` → `LINES_UNRESOLVED`
|
||
(есть строки `unreadable` / `missing` с непустым `amount`; строки без сумм не мешают) → `DUPLICATE_FORM` (в файле уже
|
||
подтверждена другая форма того же типа и периода; 409 со ссылкой на неё; между файлами работает автозамена §9.5 п. 3)
|
||
→ жёсткие нарушения без акцепта. Автоподтверждения `LINES_UNRESOLVED` не касается (`done` таких строк не
|
||
допускает). `detail` в агрегат, КФО и снимок ЕХ не попадают — цена Р-8.
|
||
- Правка даты отчёта (PATCH шапки): для колонок с `period_kind = relative:<вид>` ядро досчитывает `period_end` общим
|
||
кодом от новой даты.
|
||
- Словарь §7: единственный экземпляр в общем коде (Р-1), копия ядра удаляется. Словарь имён строк (код → наименование
|
||
по редакциям) остаётся только в ядре, копия шлюза удаляется; порядок и состав редакций ядро берёт из своей копии
|
||
перечня общего кода.
|
||
- Достройка шаблонных строк: по словарю ядра; код, у которого есть хотя бы одна строка `ok` из файла, шаблоном не
|
||
достраивается.
|
||
- **Экран** (Р-14): раскладка остаётся «как на бумаге» — разделы, внутри строки `ok` по возрастанию кода, несколько
|
||
строк одного кода в порядке файла, шаблонные строки на своих местах, пятизначные подстроки под родителем. Строка
|
||
`unreadable` / `missing` ставится под ближайшей предыдущей по `row_index` строкой, которая разместилась по коду, а
|
||
без такой — в начало первого раздела; подсвечена целиком, в ячейке кода серым сырой текст с подсказкой. Строки
|
||
`detail` скрыты под переключателем «Показать расшифровки (N)», как пустые строки детализации, показываются под
|
||
родителем с отступом. Подсветка ячеек только по `needs_review` (уверенность, `is_recognized` и нарушения §7 уже
|
||
сведены сервисом), в подсказке — `text` и уверенность; поля шапки с `needs_review` подсвечены; предупреждения и
|
||
нарушения — из `document_issue`, с привязкой к месту. В режиме «Редактировать»: ячейка кода у `unreadable` /
|
||
`missing` — поле ввода с подсказкой из справочника (код → справочное имя), меню строки «Это расшифровка»; у `detail`
|
||
— «Назначить код»; у обычной строки — «Сделать расшифровкой», смена кода и «Подтвердить код» у неуверенного;
|
||
подтверждение ячейки без правки; дата у колонки без подписи. Кнопка «Подтвердить данные» при неразобранных
|
||
строках с суммами отключена с подсказкой «Разберите строки без кода: N» и переходом к первой; сервер отвечает тем
|
||
же 409.
|
||
- Открытый вопрос: склейка двух половинок формы из одного файла не предоставляется, файл перезагружают — в
|
||
`07-open-questions.md` (Р-15). Вопрос о сопоставлении файлового наименования со справочным закрыт Р-16: для
|
||
неуверенных кодов это условие `RECOGNIZED`, для остальных — только показ справочного имени.
|
||
|
||
### Решения владельца (Р-1…Р-19 — 17.10.2026, Р-20…Р-26 — 18.10.2026)
|
||
|
||
- **Р-1** (шаг 1, п. 2) — **копия по скрипту.** Платформа не собирает Python-пакеты и не кладёт их в свой Nexus, поэтому
|
||
общий код — dataclass-ы шага 1 (сериализуемые в json), шаги 2–8, словари §7, периодов, заголовков форм, перечни и
|
||
диапазоны кодов (без словаря имён) со своим загрузчиком — живёт в отдельном репозитории `arm-statements` со своими
|
||
тестами и оформлен как пакет `arm_statements/` с `pyproject`. Границы пакета: пакет владеет своими типами входа и
|
||
выхода, хост конвертирует выход в свою модель контракта (у ядра на переходе их две); golden-тесты пакета — внутри
|
||
пакета на синтетических и обезличенных данных, тесты на реальных фикстурах остаются в scan-gateway и tabular-parser
|
||
и соседний репозиторий не читают. В tabular-parser, scan-gateway и arm-core пакет лежит копией вместе со своими
|
||
тестами и конфигом линтеров (конфиг пакета подключается к `pytest` и `ruff` хоста как include, чтобы правила не
|
||
конфликтовали), импортируется по имени пакета `arm_statements` (путь добавляется в `pyproject`), поэтому замена
|
||
копии на зависимость не трогает импорты; маркер коммита источника попадает в результат как
|
||
`service.statements_version`; раскладывает и проверяет свежесть скрипт `sync-statements` в docs по образцу
|
||
`sync-web.sh` (`--check`, код 20 при прямых правках), вызывается из `make check-full` перед PR локально — **не** из
|
||
`make check` и не из пайплайна: тест, читающий соседний репозиторий, уже ломал main (arm-core #33, пайплайн
|
||
платформы вне репозиториев). Прямые правки копии запрещены. Срочное исправление общего кода: PR в `arm-statements`
|
||
→ `sync-statements` → PR в каждом потребителе, порядок выкладки «сначала ядро».
|
||
- **Р-2** (шаг 4, п. 5) — отсутствующая позиция под колонкой — чистый `null` без собственной подсветки. Основание: по
|
||
fixtures ИС отдаёт ячейку на каждую позицию (пустая — уверенность 100, непрочитанная — 0, и её ловит общее правило
|
||
`needs_review`); отсутствие позиции бывает только при несовпадении колонок сегмента и покрыто предупреждением
|
||
шага 2 п. 2.
|
||
- **Р-3** (шаг 5, п. 1) — сервисы отдают распознанный текст наименования; соответствие «код → наименование» — только в
|
||
ядре, при необходимости (= решение № 2 по спринту 2, AURORA-1406 / 1522; договорённость ДБД «из справочника»
|
||
выполняется на экране).
|
||
- **Р-4** (шаг 3, п. 3) — повторы кодов законны; все строки сохраняются и показываются, для проверок и всех операций
|
||
после подтверждения или автоподтверждения — суммой по коду. Следствие: снять уникальность `(meta_id, line_code)` в
|
||
DBML; все чтения «одна строка на код» в ядре переводятся на агрегат.
|
||
- **Р-5** (шаг 8, «Подтверждение проверкой») — порог для значений — 100 (у ИС уверенность дискретная: 100, ~80, 71,
|
||
67, 60, 50, 25, 0, промежутка 90–99 нет; в группе 70–89 есть настоящие ошибки, например «11 30» с уверенностью 80);
|
||
ложных подсветок становится меньше за счёт подтверждения проверкой §7, а не порога. Код строки подтверждается
|
||
структурой — упорядоченным перечнем кодов формы по редакции, не уверенностью; область применения — Р-12,
|
||
дополнение — Р-16 (= решение № 7 по спринту 2, AURORA-1519 / 1524).
|
||
- **Р-6** (шаг 2, п. 6) — числовая колонка с нераспознанной подписью остаётся в документе без даты с предупреждением
|
||
`error` (`needs_check`, подтверждение руками); пропуск отвергнут как тихая потеря периода. Относительные подписи
|
||
бланка 66н («На отчётную дату», «На 31 декабря предыдущего года», …) добавляются в словарь периодов и считаются от
|
||
эффективной даты документа; после правки даты отчёта ядро досчитывает их; дату колонке можно поставить руками.
|
||
- **Р-7** (шаг 8, «Подтверждение проверкой») — **да:** сошедшееся жёсткое соотношение §7 снимает подсветку и с
|
||
`is_recognized = false`; такой документ может получить `done` и автоподтвердиться, сырой текст остаётся в подсказке
|
||
ячейки. Оговорки по ревью: соотношение должно быть посчитанным, а не «0 = 0»; для членов по модулю знак
|
||
подтверждается конвенцией колонки, а не соотношением; правило знака — только для сомнительных ячеек. Варианты
|
||
«всегда подсвечивать» и «снимать подсветку, но не автоподтверждать» отвергнуты: первый возвращает ложные подсветки
|
||
AURORA-1524, второй даёт статус «требует проверки» без подсветок.
|
||
- **Р-8** (шаг 3) — расшифровки без кода («в том числе …») отдаются сервисами со статусом `detail` и хранятся ядром; в
|
||
проверки, агрегаты и `done` не входят; экран по умолчанию их не показывает (переключатель, как у пустых строк
|
||
детализации). Отменяет OQ-36 / OQ-141.
|
||
- **Р-9** (шаг 9) — контракт **v2.0**, а не аддитивный 1.3; ядро на время перехода принимает 1.x и 2.0; порядок
|
||
выкладки — сначала ядро.
|
||
- **Р-10** (ядро) — снять уникальность `document` по `(stored_file_id, document_type, osv_account_code)` для форм
|
||
ББ / ОФР, вторая форма того же типа в файле хранится; в ту же миграцию DBML v5.19 — `crm_id` ГСК / ГСЗ из AURORA-1619
|
||
(arm-core#112).
|
||
- **Р-11** (принцип 5) — буквы-двойники заменяются и в суммах, и в кодах; результат — `is_recognized = false` у суммы и
|
||
неуверенный код, дальше их проверяют контрольные соотношения §7 и структура, и если всё сошлось, подсвечивать не
|
||
нужно (Р-7 с оговорками). Отменяет §11.1 п. 1 «только даты» и решение № 22 среза 3a. ИНН двойники не трогают.
|
||
Данные: в четырёх json ИС двойников в суммах и кодах нет вовсе — правило на вырост, не под текущие сканы.
|
||
- **Р-12** (шаг 8, «Код строки») — структурное подтверждение кода применяется только к неуверенным кодам
|
||
(уверенность ниже 100 или замена двойника); у Excel и чистых OCR-кодов расхождение с перечнем даёт `info` от ядра,
|
||
не `needs_check`.
|
||
- **Р-13** (подтверждение документа) — подтверждение документа блокируется (409 `LINES_UNRESOLVED`), пока есть строки
|
||
`unreadable` / `missing` с суммами; аналитик назначает код или отмечает строку расшифровкой в режиме правки, через
|
||
`PATCH …/statement lines` (детали — в «Что уходит из ядра»).
|
||
- **Р-14** (экран) — раскладка по коду «как на бумаге» остаётся (просьба владельца 09.10.2026); строки со статусами
|
||
ставятся под ближайшей предыдущей размещённой строкой по `row_index`, расшифровки под родителем и скрыты по
|
||
умолчанию; переключатель «как в файле» — не в этой работе.
|
||
- **Р-15** (ядро) — вторая форма того же типа и периода у одного ЮЛ из того же файла хранится; склейка половинок формы
|
||
из одного файла не предоставляется (файл перезагружают), вопрос — в `07-open-questions.md`. От ИС Сканирование
|
||
такой случай не ожидается (владелец), основной источник — Excel с листом-продолжением другого типа или двойной
|
||
печатью. Уточнено Р-18.
|
||
- **Р-16** (шаг 8, «Код строки»; ядро) — защита от подмены неуверенного кода на соседний: повтор кода, у которого хотя
|
||
бы одна копия неуверенна, — `needs_review` у этой строки (сервис); для строк с неуверенным кодом условие
|
||
`RECOGNIZED` в ядре включает совпадение распознанного наименования со справочным по коду, расхождение подсвечивает
|
||
строку. Закрывает открытый вопрос о сопоставлении наименований: для неуверенных кодов — условие автоподтверждения,
|
||
для остальных — только показ справочного имени.
|
||
- **Р-17** (шаг 3, п. 2) — разлепленные строки OQ-156 не держат документ в `needs_check` безусловно (решение 08.10.2026
|
||
заменяется): их значения нуждаются в покрытии, как грязные, и при сошедшихся соотношениях §7 не подсвечиваются;
|
||
само разлепление — предупреждение `info`.
|
||
- **Р-18** (ядро) — при двух и более формах одного типа и периода в одном файле ни одна не автоподтверждается;
|
||
подтверждение второй при подтверждённой первой — 409 `DUPLICATE_FORM` только внутри файла (между файлами работает
|
||
автозамена §9.5 п. 3).
|
||
- **Р-19** (ядро) — `RECOGNIZED` не зависит от статуса конверта: достаточно `document.status = done` и отсутствия
|
||
предупреждений `error` уровня файла (потерянный json или таблица ИС блокируют, `needs_check` соседнего документа
|
||
из-за одной ячейки — нет).
|
||
|
||
- **Р-20** (18.10.2026; ревью спеки K-4, повторы кодов) — законные повторы кодов (в реальных ББ 2025 — 1220 «НДС,
|
||
начисленный по отгрузке» и 1520 «Обязательство по аренде») допускаются в упорядоченном перечне кодов: структура
|
||
подтверждает обе копии. Правило Р-16 «повтор с неуверенной копией — `needs_review`» применяется только к повтору,
|
||
которого нет в перечне. Словарь имён ядра — список имён (синонимов) на код; загрузчик перестаёт запрещать повтор кода в
|
||
этом смысле (сверка наименования — с любым из имён). В спеке: перечень `contracts/statement-code-orders.yaml`
|
||
(`repeats`: у `fsbu4` — 1220, 1520, 2410 печатной формы 1С; у `66n` — раздел III НКО, см. S-28), §11.9, §7 «Полный
|
||
состав формы», спеки `statement-normalization` и `arm-core`.
|
||
- **Р-21** (18.10.2026; ревью спеки K-4, мягкие соотношения ОФР) — неуверенное значение подтверждается и мягким
|
||
соотношением, если оно сошлось точно (расхождение 0 или в пределах допуска ±1; не «в пределах округления»). Применяется
|
||
к 2400, 2410, 2411, 2412, 2460 и прочим строкам, у которых есть только мягкие соотношения. Знак для `abs_terms` — по
|
||
прежнему правилу конвенции колонки. В спеке: §7, §11.9, требование «Шаг 8 — `needs_review` значения».
|
||
- **Р-22** (18.10.2026; решения писателя S-1…S-25) — приняты все, с оговорками ревьюера из таблицы S-n в
|
||
`review-2026-10-18-spec.md`: S-4 — синонимы на код и исключение «итого по разделу» из правила вхождения; S-5 — в
|
||
упрощённом ОФР `2400` мягкое, как в полном, ББ — жёсткие; S-7 — OQ-148 закрывается этим решением; S-10 / S-22 — одна
|
||
формулировка; S-13 — двойники внутри детектора нумерации и допуск одного пропуска; S-15 — экранирование имени листа в
|
||
Excel-`ref`; S-17 — доопределить по K-23; S-19 — `codes` и `column_index` у `document_issue`; S-21 — по K-2 / K-21.
|
||
Явно утверждены владельцем S-4, S-5, S-7, S-10, S-12, S-13, S-14, S-20, S-22, S-25 (отмечены ниже).
|
||
|
||
- **Р-23** (18.10.2026; повторное ревью спеки L-1) — значения строк `detail` в `needs_review` и в статус документа не
|
||
входят: у них `needs_review = false`, функция статуса их не учитывает; подсказка на экране по `text` и `confidence`
|
||
остаётся. Следствие: на реальных сканах РБ и Счастливочной за 1 кв. 2025 формы могут стать `done` (по прогону
|
||
ревьюера держали их только ячейки расшифровок). В спеке: `statement-normalization` («Шаг 8 — `needs_review` значения»,
|
||
функция статуса, сценарий на РБ), `arm-core` (пересчёт после правки, согласованность), `arm-web`, §11.9, proposal; S-51
|
||
пересмотрено.
|
||
- **Р-24** (18.10.2026; L-13 и упрощение S-36) — перед выкладкой ядра база очищается от форм ББ/ОФР (документы
|
||
`BALANCE` / `INCOME`, их `financial_statement_*`, комментарии, привязки к версиям «в работе»; файлы, где кроме форм
|
||
ничего нет, — целиком с заданиями; ОСВ, консолидации, справочники не трогаются; детали и отказы — S-73). Поэтому
|
||
миграция v5.19 только добавляет колонки, индексы и значения по умолчанию; §7 для существующих форм не пересчитывается,
|
||
импорта копии `arm_statements` в ревизии Alembic нет. Миграция обязана проходить и на непустой базе (колонки по
|
||
умолчанию, `file_ordinal` по порядку, `document_issue` пуст), но корректность нарушений у старых форм не
|
||
гарантируется — сказано явно. Для приёма 1.x после выкладки (окно перехода) §7 по-прежнему считается ядром копией
|
||
пакета при приёме (остаётся из S-36). В спеке: proposal (порядок выкладки), `arm-core` («Миграция DBML v5.19»,
|
||
«Очистка форм перед выкладкой», «Формы результата 1.x — §7 при приёме»), §7 «Ядро», §9.5 п. 2, DBML v5.19 (шапка).
|
||
- **Р-25** (18.10.2026) — утверждены владельцем решения писателя S-26, S-34, S-36 (в редакции Р-24), S-37, S-41, S-47
|
||
(подтверждение без правки не сбрасывает акцепт), S-48 (в редакции L-12: ядро вызывает общую функцию статуса). Отмечены
|
||
ниже.
|
||
- **Р-26** (18.10.2026; OQ-168) — коды без образца в fixtures (2421 / 2430 / 2450 в 66н, 1120 и другие в ФСБУ 4) в
|
||
перечень кодов не входят до появления образцов; неуверенный такой код на скане — `needs_review` строки. Временное
|
||
правило, принятое владельцем; OQ-168 остаётся открытым до образцов.
|
||
|
||
## Decisions made without discussion
|
||
|
||
Решения агента 17.10.2026, принятые при оформлении целевой картины без отдельного обсуждения с владельцем; все
|
||
обратимы правкой спеки или словаря.
|
||
|
||
1. **Уровни предупреждений** (шаг 8, п. 2): `error` — только то, что меняет данные или их полноту; полный список — в
|
||
шаге 8 п. 2, таблица «какая правка какое `error` снимает» — в «Что уходит из ядра». Цена ошибки: лишний или
|
||
пропущенный `needs_check`, правится таблицей уровней в спеке.
|
||
2. **`DISCLOSURE` всегда `done`** без условий о раскладке, строках и проверках; по `type_hint` шаги 2–7 не
|
||
выполняются, по кодам прочих форм строки отбрасываются — сохранено текущее поведение парсера. Цена: пояснения и
|
||
формы 3, 4, 6 не проверяются; так и сейчас.
|
||
3. **Столбец кодов** — подпись «Код» принимается при доле кодов под ней ≥ 0,6 или кодах ≥ 3, при нескольких
|
||
кандидатах — больше кодов; запасной подсчёт «кодов ≥ 3 и доля ≥ 0,6» взят у парсера; сегмент не первый с кодами
|
||
< 3 — ослабленный поиск по диапазону столбца первого сегмента (как `relaxed` парсера). Цена: таблица без подписи и
|
||
с одним-двумя кодами в первом сегменте → документ без строк с `needs_check`.
|
||
4. **Диапазоны кодов** — явные отрезки по формам (шаг 2), объединение для поиска столбца, «диапазон столбца» для
|
||
разлепления и продолжений; границы форм 3, 4, 6 уточняются в спеке. Цена: код вне отрезка не распознаётся как код;
|
||
правится словарём.
|
||
5. **Начальный словарь исключений подписей** («Изменение», «Отклонение», «%», «Темп», «Доля», «Примечание») — по тестам
|
||
парсера; пополняется словарём, не кодом.
|
||
6. **Грамматика `locator`** — пары `ключ:значение` через `;`, ключи по источнику, как сейчас. Цена: нулевая.
|
||
7. **Грамматика `ref`** — OCR `row:<row_index>[#<k>]`, Excel `<лист>!<A1>`; `ref` строки уникален в документе.
|
||
8. **`value`, который не число** (дата, логическое) в колонке сумм — `amount = null`, `string_value` = текст,
|
||
`is_recognized = false`. Цена: минимальная.
|
||
9. **Предел и квантование суммы** — 16 целых цифр, 2 знака, half-up — только для числового `value` Excel; текст OCR с
|
||
тремя и более знаками после разделителя остаётся `null` + `string_value` (решение № 30 среза 3a); «догадка из цифр»
|
||
длиннее 16 — `null`.
|
||
10. **`parse_amount` знает источник**: запятая и точка у Excel — десятичные, у OCR — тысячи при группах из трёх цифр.
|
||
Единственное место, где шаги 2–8 смотрят на `source`. Разбор ОСВ не затрагивается.
|
||
11. **`checks` отсутствует у документа без строк**, обязателен у `BALANCE` / `INCOME` со строками.
|
||
12. **Правило `detail`**: строка без кода с содержимым под строкой с кодом (родитель продолжается через границу
|
||
сегмента); ячейка кода без единой цифры — пустая. Цена: строка формы с потерянным кодом под другой строкой
|
||
скрывается до нарушения §7 у родителя (цель отсутствует — нарушение); лечится назначением кода (Р-13).
|
||
13. **Посчитанное соотношение** — непуста цель или хотя бы один член; пустая цель при непустых членах и непустая
|
||
цель при пустых членах — нарушение; сегодня соотношение с отсутствующей целью пропускается у обоих сервисов и
|
||
ядра. Цена: формы без подытогов — только с упрощённым набором соотношений, иначе `needs_check`, как и сегодня.
|
||
14. **Упрощённая форма** определяется только по структуре кодов (ББ: есть 1600, нет 1100 и 1200; ОФР: есть 2400 или
|
||
2110, нет 2100, 2200, 2300) и получает свой набор соотношений и перечень кодов; ОКУД 0710096 признаком не служит.
|
||
Цена: ошибка определения → не те соотношения → `needs_check`; образца упрощённой формы в fixtures пока нет.
|
||
15. **Конвенция знака** для членов `abs_terms`: форма записи у большинства членов `abs_terms` колонки с непустым
|
||
`amount`, `is_recognized = true` и без правки; проверяется только у ячеек, нуждающихся в покрытии. Цена: ОФР, где
|
||
сомнительные расходы записаны не так, как остальные, подсвечивает их; это и нужно.
|
||
16. **Подтверждение даты отчёта структурой**: неуверенная дата отчёта подтверждается совпадением с самой поздней
|
||
датой колонок, неуверенная подпись колонки — попаданием в ожидаемые даты; иначе `error`. Цена: скан с верной, но
|
||
неуверенной датой без колонок с датами — `needs_check`, снимается правкой даты.
|
||
17. **Хранить `needs_review`, `text`, подтверждения, нарушения и предупреждения** в ядре (`financial_statement_value`,
|
||
`financial_statement_line`, `financial_statement_meta`, `document_issue`) вместо пересчёта на каждом GET —
|
||
пересматривает решение v5.13 «считается, не хранится». Цена: ещё одна таблица и миграция; взамен ядро при чтении
|
||
ничего не считает, а подтверждения аналитика переживают пересчёт.
|
||
18. **Эффективная дата документа** = дата отчёта, иначе самая поздняя абсолютная дата колонок — та же формула, что
|
||
`statement_period` ядра; от неё считаются относительные подписи и выбирается редакция перечня кодов.
|
||
19. **`document_issue`**: очищается при перезагрузке файла, записи `relation` / `soft` заменяются целиком при
|
||
пересчёте, `line_id` — список; `error` сервиса снимаются только правкой из таблицы соответствия.
|
||
20. **Границы пакета `arm_statements`**: свои типы входа и выхода, конвертация в модель контракта на стороне хоста,
|
||
golden внутри пакета на синтетике, тесты на реальных фикстурах — только в сервисах; расхождение версий копий —
|
||
`info`.
|
||
21. **Алгоритм структурного подтверждения кода**: наибольшая подпоследовательность по порядку бланка, редакция с
|
||
длиннейшей, пересечение при неоднозначности, уверенные коды — якоря, пятизначные — через родителя. Цена: при
|
||
двух переставленных неуверенных соседях оба `needs_review`; это и нужно.
|
||
22. **Слияние шаблонной строки с назначенным кодом**: шаблонная строка с суммами аналитика — 409 `LINE_EXISTS`, без
|
||
сумм — заменяется настоящей. Цена: лишний шаг у аналитика в редком случае; двойного счёта нет.
|
||
23. **Рабочие имена** `FormCandidate`, `TableLayout`, `RawLine`, `needs_review`, `line_status`, `line_code_text`,
|
||
`row_index`, `source_ref`, `parent_ref`, `period_kind`, `form_variant`, `document_issue`, `statements_version`,
|
||
`is_confirmed`, `LINES_UNRESOLVED`, `DUPLICATE_FORM`, `arm-statements`, `sync-statements` — по образцу существующих
|
||
(`sync-web.sh`); меняются при написании спеки без последствий.
|
||
|
||
### Решения при написании спеки (18.10.2026)
|
||
|
||
Решения агента при оформлении спеки, контракта v2.0, правил §11 и DBML v5.19 там, где дизайн оставлял «уточняется в
|
||
спеке» или не давал значения. Маркер `S-n` — в отчёте спеки; все обратимы правкой спеки, словаря или DBML до реализации.
|
||
|
||
24. **S-1. Имя способности контракта** — дельта к существующей `recognition-contract`, а не новая
|
||
`recognition-result-contract`. Основание: спека с этим смыслом уже есть в `openspec/specs/`. Цена: нулевая.
|
||
25. **S-2. Конверт `recognition-result-message` тоже v2.0**: `metrics.warnings` в конверте — те же структуры, что в файле
|
||
результата; производитель пишет в конверт тот же MAJOR. Основание: «ack тоже 1.x и 2.0» (шаг 9) и одна форма
|
||
предупреждений. Цена: ещё одна схема поднимает MAJOR; ядро и так принимает оба.
|
||
26. **S-3. Диапазоны кодов** — как в дизайне, без изменений (3100–3699, 4100–4499, 5000–5999, 6100–6499). Цена: код вне
|
||
отрезка не код; правится словарём.
|
||
27. **S-4. Совпадение наименований (Р-16):** нижний регистр, `ё` → `е`, всё кроме букв и цифр — пробел; совпало при
|
||
равенстве, вхождении более короткой строки (≥ 5 символов) или `difflib.SequenceMatcher` ≥ 0,8 с любым справочным
|
||
именем кода в любой редакции; нет кода в словаре имён — не совпало. Цена: ложное несовпадение — `NEEDS_CHECK` и
|
||
ручное подтверждение; ложное совпадение маловероятно у разных статей одного раздела. **Утверждено владельцем 18.10.2026 (Р-22)** с оговоркой: у кода несколько имён (синонимы, Р-20); если
|
||
одно из имён начинается с «итого по разделу», совпадение — только равенство (S-49).
|
||
28. **S-5. Формулы упрощённого набора** по бланку приказа 66н, прил. 5 (ББ: `1600 = 1150 + 1170 + 1210 + 1230 + 1250`,
|
||
`1700 = 1300 + 1350 + 1360 + 1410 + 1450 + 1510 + 1520 + 1550`, `1600 = 1700`; ОФР: `2400 = 2110 − 2120 − 2330 + 2340
|
||
− 2350 − 2410`), все жёсткие; `ASSUMPTION` до образца (OQ-166). Цена: неверная формула — `needs_check` упрощённых форм. **Утверждено владельцем 18.10.2026 (Р-22) с изменением:** ОФР `2400 = …` упрощённого набора —
|
||
мягкое, как `2400` полного (знак 2410, OQ-12; ревью K-11); соотношения ББ — жёсткие.
|
||
29. **S-6. Словари подписей:** кода — «код», «коды», «код строки», «код показателя»; наименования — подпись,
|
||
начинающаяся с «наименование». Цена: пополняется словарём.
|
||
30. **S-7. Прочерк** (`-`, `–`, `—`, `−` без цифр) — пустое значение, `is_recognized = true` (как пустая ячейка, Р-2);
|
||
OQ-148 — допущение. Цена: тире-мусор на месте цифры не подсвечивается сам по себе, его ловит §7 (пустой член). **Утверждено владельцем 18.10.2026 (Р-22):** OQ-148 закрыт этим решением.
|
||
31. **S-8. Каталог кодов предупреждений** (таблица §11.9 `05-domain-rules.md`); `code` в контракте — строка
|
||
`^[A-Z][A-Z0-9_]*$`, не перечисление (новый код — не MAJOR); `VALUE_OUT_OF_RANGE` — `info` (значение и так
|
||
`needs_review`). Цена: опечатка кода не ловится схемой — ловится тестами пакета.
|
||
32. **S-9. Предупреждение уровня файла** имеет `locator` (json набора ИС, лист). Цена: нулевая.
|
||
33. **S-10. `needs_review` полей шапки** — массив `header.needs_review_fields` в контракте и `jsonb` в
|
||
`financial_statement_meta`; сейчас туда попадает только `report_date`: сомнения в ИНН и организации — `info` по
|
||
дизайну, `needs_review` у них дал бы `needs_check` вопреки «НПЗ ОФР без ИНН остаётся `done`». Цена: экран подсвечивает
|
||
ИНН и организацию по `issues` с `field`, а не по `needs_review`. **Утверждено владельцем 18.10.2026 (Р-22)**; формулировка одна во всех документах:
|
||
`needs_review_fields` — поля, которые держат документ в `needs_check`; сырой текст и уверенность всех полей шапки
|
||
хранятся (`header_source`, S-33).
|
||
34. **S-11. Значения `period_kind`:** `absolute`, `relative:current`, `relative:prev_year_end`,
|
||
`relative:prev_prev_year_end`, `relative:prev_period`, `none`; «За аналогичный период» — эффективная дата − 1 год
|
||
(29.02 → 28.02). Цена: новая относительная подпись — новое значение (MINOR).
|
||
35. **S-12. Ожидаемые даты неуверенной подписи колонки** — эффективная дата, она же − 1 и − 2 года **и 31 декабря
|
||
предыдущего и предшествующего ему года**. Основание: у квартального ББ колонки «на 31.12 прошлого года», и набор
|
||
дизайна (дата, − 1 год, − 2 года) давал бы `error` на каждой такой форме. Цена: чуть шире окно подтверждения. **Утверждено владельцем 18.10.2026 (Р-22).**
|
||
36. **S-13. Строка нумерации** — не меньше трёх непустых ячеек, все целые, подряд с 1 слева направо. Цена: нумерация с
|
||
пропуском не распознаётся (строка уйдёт в данные со статусом). **Утверждено владельцем 18.10.2026 (Р-22) с оговоркой:** двойники внутри детектора и допуск
|
||
одного пропуска — уточнено S-40.
|
||
37. **S-14. Признак повторной шапки** — подпись кода в строке или ≥ 2 непустых ячеек, совпавших с шапкой первого
|
||
сегмента. Цена: шапка без «Код» и с другими подписями не начнёт сегмент. **Утверждено владельцем 18.10.2026 (Р-22).**
|
||
38. **S-15. `ref` и `row_index`:** `row_index` с 0; Excel `ref` строки — адрес ячейки столбца кодов (без столбца —
|
||
первой непустой ячейки); разлепленные `row:<i>#<k>` с k от 1; у разлепленных строк `line_code_text` и `text` —
|
||
k-е части. Цена: нулевая. Оговорка Р-22: имя листа в Excel-`ref` экранируется по правилу Excel (кавычки, удвоенный апостроф),
|
||
примеры контракта исправлены.
|
||
39. **S-16. Уверенность кода с заменой двойника** — не выше 99 (так «неуверенный» виден из одного поля). Цена: нулевая.
|
||
40. **S-17. Формат PATCH `lines`** — один элемент = одно действие (`line_code` | `line_status: "detail"` | `confirmed`);
|
||
коды 422 `INVALID_LINE_CODE`, `LINE_CODE_REQUIRED`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`, `UNKNOWN_COLUMN`; правка суммы
|
||
по `line_code` при наличии строки `ok` — 422 `UNKNOWN_CELL`. Цена: имена кодов меняются до реализации без последствий. Оговорка Р-22: доопределено по K-23 (S-47); 409 `LINE_EXISTS` переименован в `LINE_CODE_TAKEN`
|
||
(S-43).
|
||
41. **S-18. Детали 409:** `LINES_UNRESOLVED` — `detail.lines[] {line_id, row_index, line_code_text}`; `DUPLICATE_FORM` —
|
||
`detail.document_id`. Цена: нулевая.
|
||
42. **S-19. Имена DBML v5.19:** `document.file_ordinal` (с 0, уникален в файле), `financial_statement_meta.needs_review_fields`
|
||
(`jsonb`), `document_issue.line_ids` (`jsonb` — сверка ORM↔DBML не знает массивов), enum'ы `statement_line_status`,
|
||
`statement_form_variant`, `document_issue_kind`, `issue_severity`, `issue_source`; индекс `(meta_id, line_code)` остаётся
|
||
неуникальным; у строки `is_confirmed` без `confirmed_by/at` (строго по дизайну, кто — в аудите). Новые колонки — последними
|
||
в таблицах (как их добавит миграция). Цена: нулевая до миграции. Оговорка Р-22: у `document_issue` добавлены `codes` и `column_index` (S-31, S-35); висячий
|
||
`line_id` удалённой шаблонной строки убирается из `line_ids`.
|
||
43. **S-20. `related_group.crm_id` уникален** (как у ГК и ЮЛ), повтор — 409 `CRM_ID_EXISTS`. Цена: две группы с одним ID
|
||
CRM завести нельзя; так и должно быть. **Утверждено владельцем 18.10.2026 (Р-22).**
|
||
44. **S-21. Значения по умолчанию при приёме 1.x:** `ref = null`, `needs_review` строки `false`, `period_kind` — `absolute`
|
||
при дате иначе `none`, `form_variant = null`, строки-предупреждения — `LEGACY_WARNING` `info`. Цена: на окне выкладки
|
||
документы 1.x без новых сигналов качества. Оговорка Р-22: по K-2 / K-21 — S-36.
|
||
45. **S-22. Статус документа ОСВ** — `done`, когда контрольные суммы §1.4 листа сошлись, иначе `needs_check` (прежнее
|
||
правило конверта, применённое к листу). Цена: нулевая — конверт ведёт себя как сейчас. **Утверждено владельцем 18.10.2026 (Р-22)**; формулировка «контрольные суммы §1.4 листа сошлись»
|
||
теперь одна в контракте, §11, спеках `recognition-contract` и `tabular-recognition-service`.
|
||
46. **S-23. `service.statements_version` обязателен**, если в результате есть `statementDocument` (схема проверяет). Цена:
|
||
результат только с ОСВ может его не нести.
|
||
47. **S-24. Редакция перечня кодов в сервисах** — `fsbu4` при эффективной дате после 2024-12-31 (то же правило, что
|
||
`edition` ядра, OQ-145). Цена: досрочный ФСБУ 4/2023 — проверяются обе через «длиннейшую подпоследовательность». Уточнено S-26: проверяются всегда обе, дата решает только ничью.
|
||
48. **S-25. Квартал периода (условие `RECOGNIZED`):** дата не конец квартала → `NEEDS_CHECK` (как D-6), ручное
|
||
подтверждение — 409 `PERIOD_REQUIRED`. Основание: оговорка дизайна «иначе документ `RECOGNIZED`, но не подтверждаемый»
|
||
противоречит автоподтверждению `RECOGNIZED`. Цена: нулевая — поведение как сейчас. **Утверждено владельцем 18.10.2026 (Р-22).**
|
||
|
||
### Решения при доработке спеки по ревью K-1…K-25 (18.10.2026)
|
||
|
||
Решения агента при закрытии находок `review-2026-10-18-spec.md` там, где ни ревьюер, ни владелец не задали значение,
|
||
или где агент отступил от предложения ревьюера. Маркер `S-n` продолжает нумерацию; все обратимы правкой спеки, словаря
|
||
или DBML до реализации.
|
||
|
||
49. **S-26. Редакции перечня кодов — всегда обе** (K-5): для неуверенного кода считаются наибольшие
|
||
подпоследовательности по `66n` и `fsbu4`, берётся длиннейшая, эффективная дата решает только ничью. Основание:
|
||
Межениновская за 3 кв. 2025 сдана на бланке 66н — выбор только по дате (S-24) не подтвердил бы её 1120; формула
|
||
дизайна «редакция — где длиннее, при равенстве — по дате» иначе не имеет смысла. Цена: на ББ с кодами, общими для
|
||
обеих редакций, выбор безразличен; неверный выбор возможен только при равной длине — тогда решает дата, как в S-24.
|
||
Уточнено S-65 (лучшая подпоследовательность — сначала по числу уверенных кодов). **Утверждено владельцем 18.10.2026 (Р-25).**
|
||
50. **S-27. Перечень кодов — данные в docs** (K-5): источник — `contracts/statement-code-orders.yaml` (обращение «как
|
||
контракт»: общие данные сервисов и ядра), в пакете — байтовая копия `rules/statement_code_orders.yaml`, идентичность
|
||
проверяет `sync-statements --check`; форма файла — `scripts/validate_contracts.py`, для этого в `make venv` docs
|
||
добавлен PyYAML. Цена: ещё одна зависимость docs-проверок (`make venv` нужно перезапустить); перенос источника в
|
||
`arm-statements` — правкой одного требования.
|
||
51. **S-28. Состав перечня — только подтверждённые образцом коды** (K-5): шаблоны банка + коды печатных форм 1С и сканов
|
||
из `fixtures/` (1105, 1130, 1140, 1215, 1231, 1530, 2420, 2460, 2530, 2930) с источником у каждого; раздел III НКО
|
||
(1310 «Паевой фонд» … 1370, 1300 — aep_3) — законный повтор `66n`; клиентский 1231 — в `66n` (Унагранде 2024).
|
||
Коды без образца (1120 у `fsbu4`, 2421 / 2430 / 2450 у `66n`, упрощённые перечни) — OQ-168, OQ-166 (временное
|
||
правило утверждено владельцем — Р-26). Все 12
|
||
последовательностей кодов из `fixtures` (Excel и OCR) укладываются в свою редакцию целиком. Цена: на скане с
|
||
неподтверждённым кодом и неуверенностью ниже 100 — `needs_review` строки; у Excel и уверенного кода — только `info`.
|
||
52. **S-29. Окно перехода контракта** (K-8): схемы 1.2 / 1.0 лежат в docs как `*.v1.schema.json` (байтовые копии,
|
||
сервисы сверяются с ними без правки копий), примеры — `examples/v1/`; тест идентичности сервиса выбирает файл по
|
||
`$id` копии (`…/v1` → `*.v1.schema.json`) — это шаг 0 выкладки в tabular-parser и scan-gateway до слияния docs.
|
||
Отступление от предложения ревьюера «тест сверяет с MAJOR, который сервис пишет»: признак — `$id` копии, а не
|
||
настройка сервиса, чтобы правило работало без знания кода сервиса. Цена: маленький PR в двух сервисах до слияния
|
||
docs; без него их `make check` красный на окне. **Заменено S-53** (повторное ревью L-2: файла `.v1` у запроса нет,
|
||
примеры и ядро не были покрыты).
|
||
53. **S-30. Механика `sync-statements`** (K-9): копия — `api/arm_statements/` (импорт как `src`), тесты пакета в
|
||
`testpaths` хоста, `mypy src arm_statements`; маркер — файл `api/arm_statements/VERSION` с полным хэшем; `--check`:
|
||
`0` синхронно, `10` отстала (зелёный с предупреждением — иначе любой посторонний PR в сервис требовал бы
|
||
подтягивать чужие изменения пакета), `20` прямая правка / чужая версия / расхождение перечня кодов / код словаря ядра
|
||
вне перечня, `2` нет checkout или аргумент. Без checkout `arm-statements` `check-full` красный (код 2): это проверка
|
||
перед PR, а не `make check`. Цена: отставшая копия проходит `check-full`; видна в описании PR.
|
||
54. **S-31. `column_index` — номер столбца сетки кандидата** (K-6): у OCR — `column_index` ИС как есть (разреженный), у
|
||
Excel — номер столбца листа первого сегмента с 0; у `document_issue` — `column_id` для колонки формы и
|
||
`column_index` для столбца вне колонок; `line_refs` — только строки `lines[]`. Отступление от варианта «порядковый
|
||
номер колонки значений»: иначе пропущенная колонка (`COLUMN_SKIPPED`) не имела бы номера. Примеры контракта
|
||
приведены к правилу (Excel — 3, 4; OCR — 2). Цена: номера не с 0 и не подряд — экран и ядро не должны считать их
|
||
позициями. Дополнено S-56 (колонки сегментов со своей шапкой).
|
||
55. **S-32. `TABLE_PARSE_ERROR` и `SHEET_OTHER` — предупреждения файла** (K-7): с `locator`; документа нет;
|
||
`TABLE_PARSE_ERROR` (`error`) блокирует `RECOGNIZED` всех документов файла, как `JSON_FAILED`. Цена: один упавший
|
||
разбор таблицы держит весь файл на ручном подтверждении.
|
||
56. **S-33. Шапка: `header_source jsonb` + прежние `*_original_value`** (K-3): сырой текст и уверенность всех десяти
|
||
полей — одной колонкой `{поле: {text, confidence}}`; `inn_original_value`, `report_date_original_value`,
|
||
`units_original_value` заполняются как прежде, `inn_confidence` остаётся. Отступление от варианта «колонки на каждое
|
||
поле»: 20 колонок ради подсказки на экране. Цена: поиск по сырому тексту поля шапки в SQL — через `jsonb`.
|
||
57. **S-34. `organization_name` в PATCH шапки** (K-12): непустая строка до 1000 символов, `organization_name_modified`,
|
||
`organization_name_original_value` (новые колонки DBML), пересчёт `counterparty_check`, аудит; снимает
|
||
`NO_ORGANIZATION_NO_INN`. Выбран вариант «добавить», а не «убрать из правила снятия»: иначе форма без шапки
|
||
снималась бы только перезагрузкой. Цена: правка наименования влияет на сверку контрагента по алиасам. **Утверждено владельцем 18.10.2026 (Р-25).**
|
||
58. **S-35. Коды нарушений в `document_issue`** (K-13): `relation` → `RELATION_FAILED` / `error`, `soft` →
|
||
`RELATION_SOFT` / `info`, колонка `codes jsonb`, `line_ids` — все строки `ok` кодов. Цена: нулевая.
|
||
59. **S-36. Формы без `checks`** (K-2, K-21): у результата 1.x и у форм до миграции ядро один раз считает §7 копией
|
||
пакета (`form_variant = null` — полный набор) и пишет `document_issue` (`source = core`): при приёме 1.x и **в
|
||
ревизии миграции** (ревизия импортирует копию `arm_statements`). D-6 «проверки сошлись» для 1.x — из этого расчёта;
|
||
`GET` и `confirm` читают нарушения только из `document_issue`. Предупреждения маппера 1.x по документу —
|
||
`MAPPING_WARNING` `info`; строки `metrics.warnings` 1.x — `LEGACY_WARNING` в `recognition_job.metrics` (уровень
|
||
файла, не `document_issue`); `STATEMENTS_VERSION_MISMATCH` при `statements_version = null` не пишется. Цена: ревизия
|
||
миграции зависит от кода пакета на момент миграции (старая ревизия при смене пакета считает по новым правилам — для
|
||
разовой миграции приемлемо); отказ от миграционного расчёта дал бы потерю нарушений у существующих форм (K-2).
|
||
**Утверждено владельцем 18.10.2026 (Р-25) в редакции Р-24:** миграционного расчёта нет — база перед выкладкой
|
||
очищается от форм; расчёт при приёме 1.x остаётся (вариант формы — по структуре кодов, S-59).
|
||
60. **S-37. Признак строки правки — `row_index IS NULL`** (K-1): маппер 1.x и 2.0 всегда пишет `row_index`; миграция
|
||
ставит `NULL` строкам с `line_code_original_value IS NULL`; `line_code_original_value` — код из источника, правками
|
||
не меняется. Отступление от дизайна («смена кода — прежний в `line_code_original_value`»): при первой смене это то
|
||
же значение, а при повторной правке исходный код источника не теряется. Цена: «предыдущий код» после двух правок —
|
||
только в аудите. **Утверждено владельцем 18.10.2026 (Р-25).**
|
||
61. **S-38. Функция статуса и «содержимое» строки** (K-14): вход — данные без типов источника (`layout_found`, у ядра —
|
||
есть строка из источника; `has_organization_or_inn`; `period_known`; статусы и `needs_review`; число жёстких
|
||
нарушений; `accepted`; уровни предупреждений). Содержимое строки — цифра в ячейке кода, сумма в значениях или
|
||
непустая ячейка значений с уверенностью ниже 100; строка `unreadable` без значений («44 30\nI |» aep_6) — строка
|
||
данных: документ `needs_check`, подтверждение не блокирует. Цена: такая строка требует от аналитика «Это
|
||
расшифровка» или кода, хотя сумм в ней нет.
|
||
62. **S-39. Столбец кодов ищется всегда, и у `DISCLOSURE`** (K-10): `type_hint = DISCLOSURE` при кодах ББ/ОФР → тип по
|
||
кодам + `TYPE_CONFLICT`; у `DISCLOSURE` шаги 3–7 не выполняются, `columns` / `lines` — пустые массивы. Цена: лишний
|
||
проход поиска столбца по пояснениям.
|
||
63. **S-40. Детектор нумерации** (S-13 с оговоркой Р-22, K-15): ≥ 3 непустых ячеек, каждая после замены двойников —
|
||
целое; возрастают на 1, начинаются с 1 или 2, допускается один пропуск. Цена: настоящая строка данных из трёх
|
||
маленьких целых подряд («1 | 2 | 3» в суммах) станет нумерацией — на формах ББ/ОФР не встречалась.
|
||
64. **S-41. `LINES_UNRESOLVED` — сумма или текст** (K-16): блокирует строка `unreadable` / `missing`, у которой хотя бы
|
||
в одной ячейке `amount` или `string_value` не `null`. Цена: строка с мусором вместо суммы требует разбора. **Утверждено владельцем 18.10.2026 (Р-25).**
|
||
65. **S-42. Подтверждение неуверенной даты отчёта** (K-17): PATCH `header.report_date` тем же значением у поля из
|
||
`needs_review_fields` снимает его и `REPORT_DATE_UNCERTAIN`; у бланка 66н только с относительными подписями это
|
||
единственный путь — признано в §11.9. Цена: аналитик должен явно подтвердить дату на каждом таком бланке.
|
||
66. **S-43. Имена** (K-18): 409 `LINE_EXISTS` → `LINE_CODE_TAKEN` (имя занято сообщением 422 `UNKNOWN_CELL` в коде
|
||
ядра); `period_end` колонки → `period_date` во всех документах (как в контракте и DBML); порядок `lines` в API —
|
||
«по возрастанию кода, как сейчас», «как на бумаге» — раскладка экрана; `DISCLOSURE` — пустые массивы. Цена: нулевая.
|
||
67. **S-44. Откат миграции** (K-19): гарантирован на пустой и совместимой с v5.18 БД; на несовместимых данных
|
||
`downgrade` останавливается с ошибкой и ничего не удаляет. Цена: откат после появления данных v2.0 — только
|
||
восстановлением из резервной копии.
|
||
68. **S-45. «Того же периода»** (K-20) — та же пара `period_year`, `period_quarter` (как автозамена §9.5 п. 3); формы
|
||
без квартала не считаются (их и так держит `PERIOD_REQUIRED`). Цена: нулевая.
|
||
69. **S-46. Единицы и ОКЕИ** (K-22): словарь `statement_units.yaml` в пакете (написания и коды ОКЕИ 383 / 384 / 385);
|
||
единицы по тексту, иначе по ОКЕИ; `okei` (+ `_text`, `_confidence`) — в `header` контракта v2.0 и `headerField`.
|
||
Цена: ОКЕИ, противоречащий тексту единиц, не ловится (текст главнее).
|
||
70. **S-47. Пробелы PATCH** (K-23, S-17): `confirmed: false` снимает подтверждение ячейки и кода; `line_status` принимает
|
||
только `"detail"` (`"ok"` — 422 `LINE_CODE_REQUIRED`); обратного `detail` → `unreadable` / `missing` нет (исправляется
|
||
назначением кода, исходное — перезагрузкой); `columns[].period_date = null` — 422 валидации; **акцепт сбрасывает
|
||
правка данных** (сумма, код, статус строки, дата колонки, поле шапки), подтверждения (`confirmed`) — нет. Последнее
|
||
сужает правило Среза 3b «акцепт сбрасывается любой правкой формы» — вынесено владельцу (отчёт O-n). Цена: если
|
||
владелец считает подтверждение правкой — одна строка спеки. **Утверждено владельцем 18.10.2026 (Р-25):**
|
||
подтверждение без правки акцепт не сбрасывает.
|
||
71. **S-48. Страховка `done`** (K-24): `done` из результата при строке `unreadable` / `missing` со значением,
|
||
`needs_review`, `relations_failed` или `error`-предупреждении документа → `NEEDS_CHECK` и `RESULT_STATUS_INCONSISTENT`
|
||
`info`. Цена: проверка на каждом маппинге — линейная по строкам. **Утверждено владельцем 18.10.2026 (Р-25) в
|
||
редакции L-12:** согласованность — вызов общей функции статуса по входу из результата (не список признаков).
|
||
72. **S-49. Словарь имён ядра** (Р-20, S-4): повтор кода в редакции разрешён — все имена кода синонимы; шаблонная строка
|
||
и `reference_name` берут первое; «итого по разделу» — только равенство. Цена: справочное имя законного повтора на
|
||
экране — первое имя кода, файловое — рядом.
|
||
73. **S-50. Толкование Р-21:** мягкое соотношение, сошедшееся в пределах ±1, покрывает значения любого своего кода; у
|
||
кода в несошедшемся жёстком соотношении подсветка остаётся (правило «все ячейки несошедшегося жёсткого»), поэтому
|
||
на практике Р-21 действует на строки без жёстких соотношений, как сформулировал владелец. Цена: нулевая.
|
||
74. **S-51. Ожидания на реальных сканах признаны в §11.9 и proposal** (K-4): большинство из шести OCR-документов
|
||
останутся `needs_check`; формулировка гайда фронта смягчена («может стать `RECOGNIZED`»). Цена: нулевая.
|
||
**Пересмотрено Р-23:** значения `detail` статус не держат, ББ и ОФР РБ и Счастливочной за 1 кв. 2025 могут стать
|
||
`done`; держат `needs_check` aep_3 и aep_6.
|
||
75. **S-52. Манифест tabular-parser** (K-25): в спеке `tabular-recognition-service` перечислены записи, получающие
|
||
`1600 = 1700` (страницы 2 баланса ×3), и записи, чьи статусы `done` меняться не должны. Цена: нулевая.
|
||
|
||
### Решения при доработке спеки по повторному ревью L-1…L-30 (18.10.2026)
|
||
|
||
Решения агента при закрытии находок `review-2026-10-18-spec-2.md` там, где ни ревьюер, ни владелец (Р-23…Р-26) не задали
|
||
значение, или где агент отступил от предложения ревьюера. Маркер `S-n` продолжает нумерацию; все обратимы правкой спеки,
|
||
словаря или DBML до реализации.
|
||
|
||
76. **S-53. Правило теста идентичности копии контракта** (L-2; заменяет S-29): схема копии с `$id` `…/v<M>` сверяется с
|
||
`<имя>.v<M>.schema.json` docs, **если такой файл есть**, иначе с основным; пример — с `examples/v<M>/<имя>.json`, если
|
||
он есть, иначе с `examples/<имя>.json` (`M` — `MAJOR` копии схемы вида примера, вид — по префиксу имени); прочие файлы
|
||
копии (`.v<N>`, `examples/v<N>/` у ядра) — с одноимёнными; файлы docs другого `MAJOR` сервис не сверяет. Правило
|
||
вводится в шаг 0 у **всех трёх** потребителей, включая ядро. Основание: существующие тесты (`arm-core/api/tests/
|
||
test_contract.py`, `tabular-parser` и `scan-gateway` `api/tests/unit/test_contract.py`) сравнивают весь набор
|
||
`*.json` через `rglob`, включая `examples/`; правило «файл, если есть» проходит до слияния docs (файлов `.v1` нет), на
|
||
окне и после, у запроса без `.v1`. Цена: три маленьких PR до слияния docs; удаление `MAJOR` 1 — в порядке «сначала
|
||
ядро убирает копии `.v1`, затем docs».
|
||
77. **S-54. `file_ordinal` у всех документов** (L-3): индекс документа в `documents[]` результата при любом `MAJOR`, у
|
||
ОСВ, форм, `DISCLOSURE` и `OTHER`; несозданный документ (второй лист ОСВ с тем же счётом) номер не занимает и других
|
||
не сдвигает — номера с пропусками. Цена: номер не «порядковый без дыр»; экран его не показывает.
|
||
78. **S-55. Предупреждения файла и ОСВ в ядре** (L-4): `recognition_job.metrics.warnings` и `JobBrief.warnings` —
|
||
структуры `fileWarning`; строки старых заданий конвертируются при чтении в `LEGACY_WARNING` (данные не
|
||
переписываются); `JobBrief.mapping_warnings` — строки, как были. Предупреждения листа ОСВ 2.0 — `document_issue`
|
||
документа ОСВ, отдаются в новом `DocumentOut.issues[]`. `osvDocument.status` используется: ОСВ 2.0 `RECOGNIZED` ⇔
|
||
`status = done` ∧ `checks` чисты ∧ нет `error` файла ∧ контрагент не `MISMATCH` ∧ нет предупреждений маппинга листа;
|
||
статус конверта не входит (как Р-19 для форм). Отступление от гайда «поля только добавляются»: тип `JobBrief.warnings`
|
||
меняется — записано в гайде фронта. Цена: фронт обязан перейти на объекты в той же выкладке, что и ядро; ОСВ в книге
|
||
с сомнительной ОФР теперь автоподтверждается (раньше держалась статусом задания).
|
||
79. **S-56. Колонки сегмента со своей шапкой** (L-6): `columns[]` задаёт первый сегмент; колонки следующего сегмента со
|
||
своей шапкой сопоставляются с ними по `period_date` (если у каждой колонки сегмента дата есть и совпадает ровно с
|
||
одной), иначе по порядку; при сопоставлении по порядку другое число колонок — `SEGMENT_COLUMNS_MISMATCH`, лишние
|
||
столбцы сегмента в `columns[]` не попадают, их номера — в `message`. Цена: сегмент с переставленными колонками без дат
|
||
сопоставится по порядку неверно — §7 это поймает; НПЗ (лист 4 ББ со своей шапкой) остаётся `done`.
|
||
80. **S-57. Явное правило мусора в `parse_amount`** (L-7): «ядро числа» от первого «цифра, `(`, знак» до последнего
|
||
«цифра, `)`, `}`, `]`»; всё вне ядра — краевой мусор, выбрасывается с `is_recognized = false`; в ядре — только цифры,
|
||
пробелы, `,`, `.`, знак в начале и пара скобок вокруг числа, иначе `amount = null` + `string_value` (буква в ядре,
|
||
непарная скобка, символ между цифрами); крайние `|` удаляются до замены двойников; `{` и `[` открывающей скобкой не
|
||
считаются. Так `211(1 |` и `(Ю4)` дают `null`, `: : 2856983 = ■` — 2856983, `{38378)` — `null` (сценарий «знак вне
|
||
конвенции» переписан на значение без знака). Отступление от сегодняшнего `amounts.py` (выбрасывал всё, кроме цифр):
|
||
осознанно. Цена: «12:34» даёт `null` и требует внимания аналитика; «1 234 руб.» даёт 1234 с `is_recognized = false`
|
||
(буквы после последней цифры — краевой мусор, значение нуждается в покрытии §7).
|
||
81. **S-58. Дрейф перечня кодов — код 10, не 20** (L-8): `sync-statements --check` в хосте сверяет копию перечня с
|
||
docs, расхождение — `10` (предупреждение), а не `20`; `20` — только прямая правка копии, чужая версия, недостающая
|
||
зависимость пакета и (в ядре) код словаря имён вне перечня **копии**. Порядок правки перечня: docs → `arm-statements` →
|
||
хосты. Цена: копия с устаревшим перечнем проходит `check-full` с предупреждением; видна в описании PR.
|
||
82. **S-59. Точка входа пакета для пересчёта** (L-10): `StoredForm` (без сетки) → `{form_variant, edition, checks,
|
||
needs_review строк и значений, родители detail, status}`; признаки `code_fixed` / `value_fixed`, строки
|
||
`row_index = null` участвуют в §7, `needs_review_fields` — вход (пересчёт шапку не переоценивает). Ядро вызывает её
|
||
после PATCH и при приёме 1.x; вариант формы при приёме 1.x — **по структуре кодов**, а не `null` (уточняет S-36: иначе
|
||
упрощённая форма 1.x получила бы нарушения полного набора до первой правки, а после правки — другой набор). Цена:
|
||
у формы 1.x `form_variant` может отличаться от «полного по умолчанию»; образца упрощённой формы нет (OQ-166).
|
||
83. **S-60. «Итого по разделу» — сравнение номеров раздела** (L-11): номер — начальные буквы `i`/`v`/`x` первого слова
|
||
после фразы (хвост OCR «V1», «V /» отбрасывается), `strip` явно; номера равны ⇔ совпало; номер не выделен — равенство
|
||
строк. Отступление от S-49 («только равенство»): ломалось на «Итого по разделу V1» Счастливочной. Цена: «II»,
|
||
прочитанное как «I1», даст I ≠ II — ложное `NAME_MISMATCH` (безопасная сторона).
|
||
84. **S-61. «изо :» — пустой код, правило не меняется** (L-14): двойники цифрами в ячейке кода без цифр не считаются
|
||
(иначе слова, затёкшие в столбец кодов, стали бы `unreadable` и блокировали подтверждение); пример в §11.4
|
||
исправлен. Цена: потерянная строка 1130 aep_3 («изо :», `'3'` с уверенностью 0) уходит в `detail` и скрыта; её сумма
|
||
не входит в 1100, и `1100 = …` подсвечивает раздел, если сумма больше допуска ±1.
|
||
85. **S-62. Родитель `detail`** (L-15): ближайшая строка `ok` выше (`unreadable` / `missing` пропускаются; нет `ok` выше —
|
||
строка `missing`); после каждого PATCH ядро пересчитывает родителей **всех** строк `detail`; нет `ok` выше — родитель
|
||
`null`, статус остаётся `detail`. Цена: строка `detail` без родителя на экране — в начале раздела.
|
||
86. **S-63. Дата колонки руками** (L-16, L-17): ручная дата ставит `period_kind = absolute`; дата, равная текущей, у
|
||
колонки с `COLUMN_DATE_UNCERTAIN` / `COLUMN_WITHOUT_DATE` — подтверждение (аналог S-42), снимает предупреждение и
|
||
акцепт не сбрасывает. Цена: после ручной даты относительная колонка не следует за правкой даты отчёта — так и нужно.
|
||
87. **S-64. Подтверждение дат только уверенными источниками** (L-18): для неуверенной подписи колонки эффективная дата —
|
||
дата отчёта с уверенностью 100, иначе самая поздняя абсолютная дата колонок с подписью уверенности 100; неуверенная
|
||
дата отчёта подтверждается только датами уверенных подписей. Для периода и относительных подписей эффективная дата —
|
||
по всем источникам. Цена: скан, где и дата отчёта, и все подписи неуверенны, — всегда `needs_check` с подтверждением
|
||
даты руками.
|
||
88. **S-65. Структурная проверка кода — уточнения** (L-19): «лучшая» подпоследовательность — сначала по числу уверенных
|
||
кодов (якоря), затем по длине; коды вне перечня редакции в её последовательность не входят; противоречащие якоря —
|
||
входит наибольшее согласованное их множество; неуверенный код подтверждён, если входит во все лучшие; вариант формы —
|
||
по всем строкам `ok` и пересчитывается ядром после правки кода. Цена: при перепутанных уверенных соседях неуверенный
|
||
код между ними может остаться неподтверждённым — `needs_review`, безопасная сторона.
|
||
89. **S-66. Форма знака** (L-20): по результату разбора (скобки, включая `}` / `]`; минус; без знака); сама ячейка в
|
||
голосование не входит; нужно строгое большинство, равенство — не подтверждён. Цена: колонка с двумя голосующими
|
||
разной формы не подтверждает знак ни одной сомнительной ячейки.
|
||
90. **S-67. Редакция для проверок ядра** (L-21): `CODE_NOT_IN_EDITION` и `CODE_NOT_IN_DICTIONARY` — по редакции точки
|
||
входа (лучшая подпоследовательность по всем строкам `ok`, ничья — по дате); `edition` ответа API и шаблонные строки —
|
||
по дате, как сейчас. Цена: на бланке 66н, сданном в 2025 году, шаблонные строки на экране — по `fsbu4`, а проверки — по
|
||
`66n`; сведение — отдельной задачей при необходимости (O-1 отчёта).
|
||
91. **S-68. `OTHER` удалён из v2.0, `form_variant` обязателен** (L-22): в схеме v2.0 `statementDocument.document_type` —
|
||
`BALANCE` | `INCOME` | `DISCLOSURE`, `form_variant` — в `required` (`null` у `DISCLOSURE`); `OTHER` остаётся в схеме
|
||
1.x и в `document_type` ядра. Цена: нулевая — производителей `OTHER` в 2.0 нет.
|
||
92. **S-69. Откат миграции — точная формулировка** (L-24; уточняет S-44): `downgrade` останавливается только на данных,
|
||
которые v5.18 не может представить без потери первичных данных и ручного ввода (повтор кода, строка без кода, две
|
||
формы одного типа в файле, непустой `crm_id` групп, правленое наименование организации); производные и сигнальные
|
||
колонки v5.19 и `document_issue` удаляет с записью числа удалённых значений в журнал миграции (подтверждения — в
|
||
аудите). Цена: откат после работы аналитиков теряет подтверждения ячеек и кодов как признаки (история — в аудите).
|
||
93. **S-70. Механика копии пакета — уточнения** (L-25): `VERSION` из сравнения исключён (в источнике его нет); зависимости
|
||
пакета из его `pyproject` обязаны быть в зависимостях хоста (иначе `--check` = 20); пакет проходит самый строгий
|
||
конфиг `ruff` / `mypy` среди хостов; тесты пакета со своим `conftest`, `autouse`-фикстуры хоста ограничены его
|
||
каталогом `tests/`. Цена: пакет живёт по самым строгим правилам всех хостов.
|
||
94. **S-71. Содержимое таблицы ИС и `TABLE_SKIPPED`** (L-27): содержимое — хотя бы одна ячейка с цифрой или «цифрой под
|
||
видом буквы»; таблица только из текста без столбца кодов и типа — новое предупреждение файла `TABLE_SKIPPED` (`info`),
|
||
а не `TABLE_LOST` (`error`). Цена: текстовая таблица ИС не блокирует автоподтверждение; если в ней была форма без
|
||
единой цифры — такой формы не бывает.
|
||
95. **S-72. «Цифра под видом буквы» — содержимое строки** (L-30): ячейка значений, текст которой после замены двойников
|
||
без условия соседства содержит цифру и не содержит других букв (`'I'`, `'| О'`), делает строку строкой данных; текст
|
||
из настоящих букв при уверенности 100 (подписи под формой Excel: «Руководитель … Косарев», «(расшифровка подписи)» у
|
||
Межениновской) — нет: иначе каждая книга получала бы мусорные строки `detail`. Признано осознанным исключением из
|
||
«ничего не выбрасывать молча». Цена: текстовая строка без цифр с уверенностью 100 по-прежнему теряется молча.
|
||
96. **S-73. Очистка форм перед выкладкой — детали** (Р-24): скрипт ядра `scripts/purge_statements.py`, `--dry-run` по
|
||
умолчанию, `--apply` одной транзакцией; отказ без изменений, если форма упомянута в `ex_snapshot_item` или привязана к
|
||
версии не «в работе»; файлы только с формами удаляются целиком (как `DELETE /files/{id}`, без запрета
|
||
`FILE_HAS_CONFIRMED_DOCUMENTS`, задания — тоже); в смешанных файлах «ОСВ + формы» удаляются только формы, файл
|
||
остаётся — повторно его не загрузить (дедуп `sha256`), поэтому `--dry-run` перечисляет их и владелец решает про
|
||
`--include-mixed`; системный аудит `FILE_DELETE`. Цена: подтверждённые формы и их привязки к версиям «в работе»
|
||
исчезают до повторной загрузки; КФО этих версий пересчитывается.
|
||
97. **S-74. `needs_review` значений после выхода строки из `detail`** (Р-23): строка, которой назначили код, получает
|
||
`needs_review` значений по общему правилу (покрытие §7); строка, отмеченная `detail`, — `false`; значения
|
||
`unreadable` / `missing` считаются по общему правилу (документ и так `needs_check` по статусу строки). Цена: нулевая.
|