«4.200» разбирался как 4.200 с тремя знаками и отсекался решением №30
(amount = null, текст в string_value). Теперь точки строго через три цифры
([+-]?[1-9]\d{0,2}(\.\d{3})+, скобки на всю строку) — разделители тысяч,
как для запятой (#35): 4.200 -> 4200, (4.200) -> -4200, 1.234.567 -> 1234567,
verified не страдает. Дроби и смешанные записи (1.234,56) — как раньше.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
518 lines
63 KiB
Markdown
518 lines
63 KiB
Markdown
# scan-gateway
|
||
|
||
Адаптер внешней ИС Сканирование (Content AI) для АРМ КА. Принимает из NATS запросы на распознавание изображений
|
||
(PDF/PNG/JPG), передаёт файл в ИС через «горячие папки», забирает ответ, сохраняет сырой ответ в S3, нормализует его
|
||
и публикует результат в `files.tabular_parsed`. Контракт обмена — `../docs/contracts/` (v1.2: `statementDocument`;
|
||
`schema_version` в конвертах и файле результата сервис по-прежнему пишет `1.0` — `CONTRACT_VERSION`, решение №14
|
||
change'а `slice-3a-ocr-normalizer`: поля 1.1–1.2 документы PDF-пути не используют), архитектура —
|
||
`../docs/context/03-architecture.md`.
|
||
|
||
Границы сервиса (DECISION-4):
|
||
|
||
- нет базы данных: единственный писатель в БД — `arm-core`; шлюз видит только NATS, S3 и горячие папки;
|
||
- нет фронта; HTTP — только `GET /healthcheck`;
|
||
- сырой ответ ИС лежит в S3 (`raw/{job_id}.json`) и в БД не регистрируется;
|
||
- саму ИС Сканирование мы не пишем: для разработки есть эмулятор (`api/src/scan_emulator/`).
|
||
|
||
Состояние: Срез 3a. Сквозной путь работает, ответ ИС нормализуется в `statementDocument` v1.2 (раздел «Нормализация»);
|
||
раскладка горячих папок — как в проверенном в контуре `armka_file_worker` (`<job_id>.<ext>`). Известные ограничения —
|
||
в конце файла. Правила для агента и соглашения репозитория — `CLAUDE.md`; требования и решения —
|
||
`openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/` (`design.md`: таблица «Decisions made without discussion») и архив
|
||
Среза 0 `openspec/changes/archive/2026-10-04-slice-0-skeleton/`.
|
||
|
||
## Поток обработки
|
||
|
||
```
|
||
files.img_uploaded (NATS, queue group scan-gateway)
|
||
1. разбор и проверка запроса ──нет/не по схеме──► error SCHEMA_MISMATCH (без ack)
|
||
2. ack: files.tabular_parsed, status=processing (до чтения S3 и до ожидания слота)
|
||
3. слот (SCAN_MAX_INFLIGHT одновременных заданий)
|
||
4. файл из S3 (s3.bucket/s3.key)
|
||
5. горячая папка: <Input>/<job_id>.<ext> (сразу, wb, существующий перезаписывается)
|
||
6. опрос <Output>/<job_id>/ каждые SCAN_POLL_SECONDS до дедлайна; набор *.json готов, когда
|
||
не менялся SCAN_SETTLE_SECONDS
|
||
7. склейка всех *.json каталога ──► S3 raw/{job_id}.json (до нормализации)
|
||
8. очистка горячей папки (только при SCAN_CLEANUP=true)
|
||
9. нормализатор (OcrNormalizer) ──► (done/needs_check) S3 results/{file_id}/{job_id}/result.json
|
||
10. files.tabular_parsed: итоговый конверт (done | needs_check | error), в нём raw_s3_key
|
||
```
|
||
|
||
Каждое задание заканчивается опубликованным конвертом: сбой превращается в `error` с кодом (таблица ниже), а не в
|
||
молчание. Если не удалось даже опубликовать (после повторов) — запись в лог уровня error, ядро закроет задание по
|
||
своему `reply_deadline`. Падение шлюза после ack теряет задание до `reply_deadline` (без JetStream иначе нельзя).
|
||
|
||
Повторный запрос с `job_id`, который уже в работе или в LRU обработанных (`DONE_CACHE_SIZE`), получает повторный
|
||
ack (с `attempt` повторного запроса); файл в ИС второй раз не отправляется.
|
||
|
||
## Протокол горячих папок
|
||
|
||
Реализации интерфейса `HotFolder` (`api/src/gateway/hotfolder.py`: `put`, `poll`, `cleanup`, `healthy`):
|
||
|
||
| Режим | Реализация | Для чего |
|
||
|---|---|---|
|
||
| `local` (по умолчанию) | `LocalHotFolder`, обычный каталог `SCAN_HOTFOLDER_PATH` | разработка, тесты, эмулятор |
|
||
| `smb` | `SmbHotFolder` (`api/src/gateway/smb.py`, `smbprotocol`), шара из `OPENBAO__SMB__*` | платформа |
|
||
|
||
Раскладка совпадает с проверенной в контуре (`RSHB/armka_file_worker`, OQ-137): `Input/<job_id>.<ext>` →
|
||
`Output/<job_id>/*.json`.
|
||
|
||
- Входящий файл: `<SCAN_INPUT_DIR>/<job_id>.<ext>`. `<ext>` — суффикс после последней точки последнего компонента
|
||
`original_name` запроса (разделители пути `/` и `\` учитываются), в нижнем регистре, 1–16 букв и цифр ASCII
|
||
(`aep_5.PDF` → `<job_id>.pdf`): ИС различает форматы по расширению, а имя файла от пользователя в горячую папку не
|
||
попадает. Нет допустимого расширения — `RECOGNITION_FAILED` без записи в горячую папку.
|
||
- Запись — **сразу в `Input`**, как в старом АРМ (решение владельца 09.10.2026, change `hotfolder-direct-write`):
|
||
файл открывается на запись с усечением (`wb`) и пишется целиком; временного файла, служебного каталога и
|
||
переименования нет. В режиме `smb` — `smbclient.open_file(<Input>\<имя>, "wb")`: файл открыт монопольно, пока идёт
|
||
запись, и ИС его не прочитает (ASSUMPTION(OQ-79) — так работал старый АРМ). Если `Input/<job_id>.<ext>` уже существует
|
||
(повтор задания ядром после потери ack в другом процессе), он **перезаписывается** содержимым объекта S3, задание
|
||
продолжается опросом `Output/<job_id>/`. Ошибка посреди записи → `RECOGNITION_FAILED`; недописанный файл снимается
|
||
только при `SCAN_CLEANUP=true` (иначе остаётся в `Input`). Каталог `Input` создаётся при необходимости (без
|
||
родителей; отказ в правах при существующем каталоге — не ошибка), корень горячей папки сам не создаётся: нет корня —
|
||
`RECOGNITION_FAILED`.
|
||
- Ответ ИС — каталог `<SCAN_OUTPUT_DIR>/<job_id>/`: ИС кладёт туда **все** распознанные документы задания одновременно,
|
||
по одному `*.json` на файл (`Output/<job_id>/*.json`). Шлюз читает **все** `*.json` каталога и склеивает их в один
|
||
ответ; прочие файлы каталога игнорируются. `job_id` проверяется на безопасность пути. В одном json может быть
|
||
несколько таблиц разных форм (баланс, ОФР); нормализатор делает из каждой таблицы отдельный документ.
|
||
- Готовность набора — по стабилизации (settle): в каталоге есть хотя бы один `*.json`, а имена и размеры `*.json` не
|
||
менялись `SCAN_SETTLE_SECONDS` (по умолчанию 2 с). Отсчёт идёт от первого опроса, который увидел окончательный
|
||
набор (а не от времени изменения файлов), поэтому ответ приходит не раньше чем через `SCAN_SETTLE_SECONDS` после
|
||
такого опроса и не позднее `SCAN_SETTLE_SECONDS + SCAN_POLL_SECONDS` после первого опроса, увидевшего набор; `0` — готов любой непустой набор. Появление или изменение файла сбрасывает отсчёт; набор, который так
|
||
и не стабилизировался до дедлайна, даёт `TIMEOUT`.
|
||
- Таймаут ожидания: `min(reply_deadline, момент записи файла + SCAN_TIMEOUT_SECONDS)`; опрос каждые `SCAN_POLL_SECONDS`
|
||
(ожидание settle входит в этот срок).
|
||
Ошибка чтения «Исходящих» не прерывает ожидание. Если срок вышел, пока задание ждало слот, файл в ИС не отправляется.
|
||
- Повторной отправки в ИС нет (OQ-15): после `TIMEOUT` входящий файл остаётся в каталоге, поздний ответ ИС теряется
|
||
(сырой файл остаётся на шаре).
|
||
|
||
### Очистка горячей папки (`SCAN_CLEANUP`)
|
||
|
||
Очистка включается `SCAN_CLEANUP=true`; **по умолчанию выключена**: на SMB-шаре контура права на удаление не
|
||
подтверждены (открытый вопрос Среза 3a). Для dev-стенда (compose, эмулятор) флаг стоит включать.
|
||
|
||
- **Когда.** Воркер вызывает `HotFolder.cleanup` сразу после того, как набор ответа прочитан, склеен и сохранён в
|
||
`raw/{job_id}.json` — при любом исходе разбора и нормализации (в том числе когда все файлы набора — ошибки ИС).
|
||
- **Что удаляется** (всё, что есть): **всё содержимое** каталога `Output/<job_id>/` (не только `*.json`) и сам каталог,
|
||
`Input/<job_id>.<ext>`, `Input/<job_id>.<ext>.done` (след эмулятора). `Tmp` шлюз не трогает. Отсутствующий файл
|
||
или каталог не ошибка. В режиме `local` каталог ответа удаляется рекурсивно; в режиме `smb` удаляются файлы каталога, а
|
||
подкаталог внутри `Output/<job_id>/` не даст снять каталог (`rmdir` → `STATUS_DIRECTORY_NOT_EMPTY`): ошибка очистки
|
||
поднимается и логируется, остальные пункты (входящий файл, `.done`) в этом задании не удаляются.
|
||
- **Когда не вызывается:** `TIMEOUT` (ИС может ещё писать ответ), `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи во
|
||
входящий каталог, ошибка записи `raw/` в S3. Входящий файл и каталог ответа тогда остаются.
|
||
- **Ошибки удаления** (например, `PermissionError` на шаре): одна строка в логе (`warning`) на задание; конверт,
|
||
`metrics.warnings` и `/healthcheck` не меняются — исход задания определён разбором ответа.
|
||
- **Флаг управляет всем удалением:** при `SCAN_CLEANUP=false` сервис не удаляет ничего — ни каталог ответа, ни входящий
|
||
файл и `.done`, ни недописанный при ошибке записи входящий файл — и не пишет об этом в лог (решение №27). Остатки
|
||
`Tmp/.*.part` от версий шлюза до change `hotfolder-direct-write` сервис не трогает — убрать вручную.
|
||
- Права на SMB: для записи учётной записи шары нужно создание и перезапись файлов в `Input`, для чтения ответа —
|
||
листинг и чтение `Output`; для очистки — удаление в `Input` и `Output` (включая каталог `<job_id>`). Пока права на удаление не
|
||
подтверждены, оставляйте `SCAN_CLEANUP=false`.
|
||
|
||
### Эмулятор ИС в режиме `local`
|
||
|
||
Для файла `<uuid>.<ext>` эмулятор ждёт `SCAN_EMULATOR_DELAY` и кладёт в `Output/<uuid>/` набор эталонных ответов. Пара
|
||
ищется **по sha256 содержимого** входящего файла, а не по имени: эмулятор при старте индексирует `SCAN_FIXTURES_DIR`
|
||
(для каждого файла, не являющегося `*.json`, — sha256 → основа имени без расширения, `aep_6.pdf` → `aep_6`; индекс
|
||
пересчитывается, когда меняется список файлов каталога). При совпадении хеша в наборе все `<основа>.json` и
|
||
`<основа>_<суффикс>.json` → `<uuid>.json` и `<uuid>_<суффикс>.json` (именно `<основа>_*`, а не префикс `<основа>*`:
|
||
`aep_3` не забирает `aep_30*`; в каждом файле `file_uid` = `<uuid>`). **Промах по хешу:** набор `SCAN_EMULATOR_DEFAULT`
|
||
(основа имени, например `aep_6`), если он задан и такой набор есть, в логе отметка «по умолчанию»; иначе один
|
||
ответ-ошибка `INVALID_DOCUMENT` с текстом `<первые 12 символов sha256>: нет пары по содержимому в фикстурах`. Набор
|
||
собирается во временном каталоге `Tmp` и появляется в `Output/<uuid>/` атомарно целиком (rename каталога). Затем
|
||
входящий файл переименовывается в `….done`. Файлы с точки, `*.part`, `*.tmp`, `*.done` игнорируются.
|
||
|
||
## Склеенный ответ ИС (`raw/{job_id}.json`)
|
||
|
||
В S3 до нормализации сохраняется **склейка** всех `*.json` каталога ответа (контракт v1.0 не меняется: `raw_s3_key` —
|
||
по-прежнему одна строка `raw/{job_id}.json`):
|
||
|
||
```json
|
||
{
|
||
"job_id": "<job_id>",
|
||
"files": [
|
||
{"name": "<job_id>.json", "response": {"file_uid": "...", "error": {"code": "", "message": ""}, "data_ocr": {}}, "parse_error": null},
|
||
{"name": "<job_id>_ofr.json", "response": {"file_uid": "...", "error": {"code": "INVALID_DOCUMENT", "message": "..."}}, "parse_error": null}
|
||
]
|
||
}
|
||
```
|
||
|
||
`files` — все `*.json` каталога по возрастанию имени. Файл, который не разобрался как JSON, не роняет задание:
|
||
`response = null`, `parse_error` — текст ошибки. Нормализатору передаётся ровно этот объект.
|
||
|
||
Формат файла ответа Content AI: `{"file_uid", "error": {"code", "message"}, "data_ocr": {...}}`. **Успех — это
|
||
`error.code == ""`** (`error` при этом не `null`: `{"code": "", "message": ""}`), поэтому проверять `error != null`
|
||
нельзя. Ошибка — непустой `error.code` (`INVALID_DOCUMENT`, ...).
|
||
|
||
## Нормализация (`statementDocument` v1.2)
|
||
|
||
`OcrNormalizer` (`api/src/normalizer/ocr/`) превращает склеенный ответ ИС в документы контракта v1.2 по `§11` и `§11.1`
|
||
`../docs/context/05-domain-rules.md`: из каждой таблицы `data_ocr.table_structure[i]` каждого успешного файла получается
|
||
один `statementDocument` (`locator = "file:<имя json>;table:<i>"`). Проверенные на стенде функции очистки старого ядра
|
||
перенесены дословно (единственное исключение для `reference-old-arm`, решение владельца 06.10.2026), DB-обвязка не
|
||
переносится. Нормализатор — чистые функции без I/O и настроек; `checks` в документах не передаются (их считает ядро).
|
||
|
||
Конвейер (`src/normalizer/ocr/`): `text.py` (двойники букв и цифр, `|` как разделитель клеток, `clean_value`) →
|
||
`dates.py` (`parse_date`: даты и периоды вида «на 30 июня 2025 г», «за январь - июнь», «1 квартал», «9 месяцев» → последний день периода) →
|
||
`amounts.py` (`parse_amount`: запятая → точка, скобки → минус, прочерк → `null`) → `layout.py` и `rows.py` (раскладка
|
||
колонок по шапке с ячейкой «Код» или, если шапки нет, по данным; классы строк; повторная шапка перезаписывает
|
||
раскладку) → `header.py` (организация, ИНН, единицы, дата отчёта из `data_ocr`) → `document.py` (тип документа,
|
||
колонки, строки, значения с `is_recognized`) → `__init__.py` (`OcrNormalizer.normalize`: обход файлов склейки, статус,
|
||
`metrics`).
|
||
|
||
Что получается в документе (подробно — требование «Нормализация ответа ИС» спеки `openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/`):
|
||
|
||
- `document_type` — по `label` таблицы (`statement_labels.yaml`), если не сопоставился — по первой цифре кодов строк
|
||
(`1…` → `BALANCE`, `2…` → `INCOME`), иначе `OTHER` (документ всё равно отдаётся, статус `needs_check`).
|
||
- `columns` — колонки значений первой шапки, перенумерованные с 0; `period_date` — из подписи колонки.
|
||
- `lines` — только строки данных с целым кодом. Повтор кода: остаётся первая строка (в БД `line_code` уникален в
|
||
документе), остальные теряются в документе, но остаются в `raw/`.
|
||
- `values` — по одному на колонку документа; `amount` — число (в файле результата деньги строкой) или `null`;
|
||
`string_value` — текст ячейки, только когда сумма не разобрана; `is_recognized = confidence == 100 и разбор не
|
||
отбрасывал символов`.
|
||
- **`Money` (решение №30).** Контракт допускает не больше двух знаков после запятой. Сумма с тремя и более знаками
|
||
(`0,125`, `12,3456`; запятая-разделитель тысяч `4,200`/`1,234` — это 4200/1234, AURORA-1405, #35; точка так же: `4.200`/`837.994` — 4200/837994, #37) не принимается: `amount = null`, `string_value` — текст
|
||
ячейки, `is_recognized = false` (таблица получает `needs_check`, остальные ячейки и документ целы). Точка-разделитель тысяч — только строго через три цифры (`[+-]?[1-9]\d{0,2}(\.\d{3})+`, скобки на всю
|
||
строку; `4.200`, `(4.200)`, `1.234.567`), без потери `is_recognized`; `4.2`, `4.20` — дроби, `1.2345` — текст (№30).
|
||
Смешанная сумма вида `1.234,56` (разделители обоих видов) разбирается как `123456` — все точки считаются разделителями тысяч (дословный перенос) —
|
||
с `is_recognized = false`.
|
||
- `confidence` ячеек и полей шапки приводится к диапазону 0–100, `NaN` и бесконечность дают 0 (`json.loads` принимает
|
||
`NaN`/`Infinity`, а контракт требует 0–100).
|
||
- `header.report_date` — после замены двойников; `inn` — только цифры. Тексты дат (дата отчёта, подпись колонки) длиннее
|
||
200 символов не разбираются и не проходят замену двойников (решение №31: регулярки дат на длинном тексте работают
|
||
неприемлемо долго).
|
||
|
||
### Справочники `api/rules/`
|
||
|
||
Данные, а не код; загружаются один раз при старте из `SCAN_RULES_DIR` (по умолчанию `rules`, путь от `api/`) с
|
||
pydantic-валидацией; битый файл (код не из 4–5 цифр, пустое наименование, дубль `id`, **повторяющийся ключ yaml** — например,
|
||
один код строки дважды в `lines`; решение №33) — сервис не стартует, в ошибке имя файла (и ключ с номером строки).
|
||
|
||
| Файл | Содержимое |
|
||
|---|---|
|
||
| `statement_lines.yaml` | `editions`: список редакций форм `{id, valid_from?, valid_until?, lines: {код: наименование}}` |
|
||
| `ocr_lookalikes.yaml` | `digits`: символ, который OCR ставит вместо цифры → цифра (применяется только к датам, вплотную к цифрам) |
|
||
| `statement_labels.yaml` | `labels`: нормализованная (нижний регистр, не-буквенно-цифровое → пробел) подстрока `label` таблицы → `BALANCE` / `INCOME` |
|
||
|
||
Редакции форм: `66n` (Приказ Минфина № 66н, отчётность по 2024 год включительно, `valid_until: 2024-12-31`; заполнена из
|
||
`codes_mapping` старого ядра) и `fsbu4` (ФСБУ 4/2023, отчётность с 2025 года, `valid_from: 2025-01-01`; **пока
|
||
пуста** — данные положит владелец). Редакция документа выбирается по `header.report_date`, если его нет — по самой
|
||
поздней `period_date` колонок, если нет и её — по числу совпадений кодов (с предупреждением). Имя строки берётся из
|
||
словаря выбранной редакции по коду; если кода в нём нет — из ячейки наименования OCR. Пустая выбранная редакция —
|
||
предупреждение «редакция fsbu4 не заполнена», имена из OCR, `needs_check`.
|
||
|
||
Как заполнить `fsbu4`: в `api/rules/statement_lines.yaml` заменить `lines: {}` у редакции `id: fsbu4` на таблицу
|
||
`"<код>": "<наименование>"` (коды — строки из 4–5 цифр в кавычках, без дублей; наименования непустые; источник —
|
||
Приложения 3–4 ФСБУ 4/2023, черновой перечень кодов — шаблоны `fixtures/statements-xlsx/templates/`). Код нормализатора не
|
||
меняется. Затем: `cd api && .venv/bin/pytest tests/unit/normalizer/test_rules.py` (валидность данных),
|
||
`make golden-update` и `git -C ../fixtures diff` (эталоны документов 2025 года изменятся: имена строк и предупреждения —
|
||
объяснить в PR fixtures), перезапуск сервиса (справочники читаются при старте).
|
||
|
||
Тесты, которые закрепляют **пустую** `fsbu4` и после заполнения упадут — их нужно обновить (список получен поиском по
|
||
`fsbu4` / `не заполнена`):
|
||
|
||
- `api/tests/unit/normalizer/test_document.py`: `TestTypeAndEdition::test_edition_2025_is_empty` (имя строки из OCR,
|
||
предупреждение «редакция fsbu4 не заполнена») и `TestEditionWarnings::test_empty_edition_gives_single_warning`
|
||
(`warnings == ["редакция fsbu4 не заполнена"]`);
|
||
- `api/tests/integration/test_roundtrip.py`, сценарий 3 (`aep_6.pdf`, тест `test_real_fixture_aep_6_needs_check_with_two_documents`:
|
||
строки «редакция fsbu4 не заполнена» для `table:0` и `table:1`) и сценарий 4 (`ok_doc.pdf`, тест
|
||
`test_synthetic_success_answer_gives_needs_check`: имя строки `1600` «БАЛАНС» из OCR, единственное предупреждение про
|
||
пустую редакцию, статус `needs_check`; при заполненной редакции с кодом `1600` этот документ станет `done`), а также
|
||
комментарии к ним в начале файла;
|
||
- эталоны `../fixtures/ocr/expected_normalize/aep_6.json` (предупреждения «редакция fsbu4 не заполнена», имена строк
|
||
2025 года из OCR) — их проверяет `api/tests/unit/normalizer/test_golden.py` (обновляются `make golden-update`);
|
||
- `api/tests/unit/normalizer/test_ocr_normalizer.py` и `api/tests/unit/test_worker.py` (`aep_6`, `SUCCESS_ANSWER` с датой
|
||
2025 года) пустую `fsbu4` напрямую не закрепляют: проверяют `needs_check`, который остаётся из-за `confidence` < 100.
|
||
|
||
### Статус результата
|
||
|
||
- Все файлы набора ошибочны — `error` с кодом первого ошибочного файла по имени (см. «Коды ошибок»).
|
||
- Из успешных файлов не собран ни один документ (нет ни одной таблицы с шапкой или столбцом кода, `table_structure`
|
||
пуст или это не объект `data_ocr`) — `error`, `RECOGNITION_FAILED`, сообщение «ни одной таблицы не разобрано»;
|
||
причины по таблицам — в `metrics.warnings`; `raw_s3_key` заполнен.
|
||
- Документы есть — `done`, если всё распознано и ни один файл набора не ошибочен: у всех документов `inn_confidence` и `organization_name_confidence`
|
||
равны 100; дата отчёта разобрана и распознана; все подписи колонок разобраны в даты; все `line_code_confidence` и
|
||
`line_name_confidence` равны 100; все значения `is_recognized`; тип по `label` совпал с типом по кодам; редакция
|
||
определена по дате отчёта или дате колонки и заполнена; нет предупреждений уровня таблицы и файла нормализации. Иначе `needs_check`.
|
||
Единицы (`units`) в критерий не входят (в контракте у поля нет `confidence`). Реальные сканы почти всегда дают
|
||
`needs_check`: `done` означает лишь «нечего подсвечивать аналитику».
|
||
- Ошибочный файл набора при наличии успешного (например, `<job_id>_ofr.json` с `INVALID_DOCUMENT`) **понижает статус до
|
||
`needs_check`** (решение №28: потерянный ОФР не должен остаться незамеченным под `done`); документы собираются из
|
||
успешных файлов, а ошибочный файл попадает в `metrics.warnings` строкой `<имя>: <код ИС>: <сообщение>`.
|
||
- `metrics.rows` — число строк во всех документах. Файл результата `results/{file_id}/{job_id}/result.json` пишется для
|
||
`done` и `needs_check`; предупреждения (`metrics.warnings`) лежат и в конверте, и в файле результата.
|
||
|
||
### Предупреждения (`metrics.warnings`)
|
||
|
||
Одна строка на предупреждение; уровня таблицы — `<имя json>: table:<i>: <текст>`, уровня файла — `<имя json>: <текст>`,
|
||
ошибочного файла — `<имя json>: <код ИС>: <сообщение>` (код `INVALID_FORMAT` для файла, не разобранного как JSON, и
|
||
для корня-не-объекта). Строка обрезается до 1000 символов, строк не больше 100: лишние сводятся в последнюю
|
||
(`ещё предупреждений: <N>; все ошибки файлов — в raw/<job_id>.json`). Тексты (из `src/normalizer/ocr/`):
|
||
|
||
| Уровень | Текст |
|
||
|---|---|
|
||
| файл | `ИНН не распознан` (в поле `inn` нет цифр, ИНН не извлечён) |
|
||
| файл | `ИНН распознан неуверенно` (ИНН извлечён, но `confidence` ≠ 100 или из текста убран мусор) |
|
||
| файл | `дата отчёта не разобрана` |
|
||
| файл | `data_ocr отсутствует или не объект` |
|
||
| таблица | `нет шапки и столбца кода` (таблица пропущена, остальные разбираются; также когда `rows` не список или у таблицы есть шапка, но нет ни одной строки данных) |
|
||
| таблица | `строк с нечисловым кодом пропущено: <N>` (непустой код, не целое число: `1150 :`, `изо :`; одно на таблицу) |
|
||
| таблица | `строк без кода с суммами пропущено: <N>` (пустой код, но в колонках значений есть разобранная сумма: «БАЛАНС», «Выручка»; как в Excel-пути, OQ-141; строки без кода и без сумм — заголовки групп, «в том числе:» — не считаются; одно на таблицу) |
|
||
| таблица | `строк с несколькими кодами разлеплено: <N>` (ИС склеила соседние строки: код `1500\n1700`, суммы через перевод строки; строка разлеплена на N строк, OQ-156; одно на таблицу) |
|
||
| таблица | `шапка синтезирована: колонки без дат` (в таблице нет строки-шапки: раскладка выведена по данным) |
|
||
| таблица | `колонка <i> без даты: <подпись колонки>` |
|
||
| таблица | `нет колонок значений` |
|
||
| таблица | `повторная шапка изменила число колонок значений` (одно на таблицу; значения лишних колонок отброшены, недостающие — «ячейки нет») |
|
||
| таблица | `тип по label (<label>) не совпал с кодами: взят <тип>` (все коды строк данных одной группы — `1xxx` или `2xxx`, а `label` говорит иное: берётся тип по кодам, `header.form_name` — как распознан; #30) |
|
||
| таблица | `тип по кодам (<тип>) не совпал с label (<label>)` (коды обеих групп — шум OCR: берётся тип по `label`) |
|
||
| таблица | `тип определён по кодам` (`label` не сопоставлен) |
|
||
| таблица | `тип не определён: OTHER` |
|
||
| таблица | `редакция определена по кодам: <id>` (нет ни даты отчёта, ни дат колонок) |
|
||
| таблица | `редакция не определена` |
|
||
| таблица | `редакция <id> не заполнена` (сейчас — `fsbu4`) |
|
||
| таблица | `код <код> из редакции <id>` (код есть в другой редакции, но не в выбранной; **только если выбранная редакция не пуста**) |
|
||
| таблица | `код <код> повторяется: оставлена первая строка` |
|
||
| таблица | `BACKEND_PARSING_ERROR: <класс исключения>: <сообщение>` (исключение при разборе таблицы: остальные таблицы разбираются) |
|
||
|
||
Любое предупреждение уровня таблицы или файла нормализации означает `needs_check`.
|
||
|
||
### Эталоны и `make golden-update`
|
||
|
||
Тест `api/tests/unit/normalizer/test_golden.py` сравнивает результат нормализатора на реальных ответах ИС
|
||
(`../fixtures/ocr/<имя>.json` + `<имя>_*.json`) с эталонами `../fixtures/ocr/expected_normalize/<имя>.json` (`status`,
|
||
`warnings`, `metrics`, `error`, `documents`). Эталоны лежат в репозитории **fixtures** и обновляются отдельным PR:
|
||
`make golden-update` (`api/tests/golden_update.py`) пересобирает их, diff смотреть через `git -C ../fixtures diff` и
|
||
объяснять. Без каталога `../fixtures/ocr` golden-тесты не выполняются.
|
||
|
||
## Переменные окружения
|
||
|
||
Файл окружения — `ENV_FILE` (по умолчанию `.env`, путь от текущего каталога, то есть от `api/`); переменные окружения
|
||
приоритетнее файла. Шаблон — `api/.env.example`. Настройки шлюза читаются при импорте `src.app`: модуль создаёт `app`
|
||
(это происходит при запуске `run_server.py` или `uvicorn src.app:app`). Импорт пакета `src` приложение не создаёт, поэтому
|
||
эмулятор (`python -m src.scan_emulator`) настройки шлюза не читает: у него свои переменные (`SCAN_HOTFOLDER_PATH`,
|
||
`SCAN_FIXTURES_DIR`, `SCAN_EMULATOR_*`) из окружения и `ENV_FILE`.
|
||
|
||
| Переменная | По умолчанию | Назначение |
|
||
|---|---|---|
|
||
| `OPENBAO__APP__<ИМЯ>` | — | **контур платформы:** туда передаются только секреты OpenBao (`OPENBAO__*`), обычные переменные окружения задать нельзя. Любая настройка верхнего уровня из этой таблицы (`SCAN_HOTFOLDER_MODE`, `NATS_ENABLED`, `DEBUG`, `SCAN_*`, …) может прийти как `OPENBAO__APP__<ИМЯ>`, например `OPENBAO__APP__SCAN_HOTFOLDER_MODE=smb` (путь шары — по-прежнему секрет `OPENBAO__SMB__SMB_PATH`, `SCAN_HOTFOLDER_PATH` в контуре не нужен). Значения разбираются как из обычного env. Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию. Неизвестное имя — ошибка конфигурации при старте; `OPENBAO__APP__OPENBAO__…` не допускается. Читается только из окружения процесса (в `.env` не действует). Реализация — `api/src/core/settings_sources.py` (тот же файл в arm-core и arm-tabular-parser) |
|
||
| `OPENBAO__MINIO__MINIO_URL` | — (обязательна) | адрес MinIO `host:port`, без схемы |
|
||
| `OPENBAO__MINIO__MINIO_ADMIN_LOGIN` | — (обязательна) | логин MinIO |
|
||
| `OPENBAO__MINIO__MINIO_ADMIN_PASSWORD` | — (обязательна) | пароль MinIO |
|
||
| `OPENBAO__MINIO__MINIO_BUCKET` | — (обязательна) | единственный бакет сервиса; запрос с другим `s3.bucket` -> `FILE_READ_ERROR` |
|
||
| `OPENBAO__MINIO__MINIO_SECURE` | `true` | https; для MinIO из compose — `false` |
|
||
| `OPENBAO__NATS__NATS_URL` | — (обязательна) | адрес NATS |
|
||
| `OPENBAO__NATS__NATS_SEED`, `OPENBAO__NATS__NATS_JWT` | пусто | nkeys/JWT платформы (`SecretStr`, в логи и `/healthcheck` не попадают); оба пусты — без учётных данных; задан один — ошибка конфигурации |
|
||
| `OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT` | `files.img_uploaded` | subject запроса, на который подписан сервис; у ядра окружения то же значение |
|
||
| `OPENBAO__NATS__NATS_RESULT_SUBJECT` | `files.tabular_parsed` | subject, в который сервис публикует ack и итог |
|
||
| `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT` | `files.tabular_uploaded` | запрос `tabular` (сервис его не слушает; значение участвует в проверке: три имени непусты, без пробелов, `*`, `>` и попарно различны, иначе сервис не стартует) |
|
||
| `OPENBAO__SMB__SMB_HOST`, `OPENBAO__SMB__SMB_PATH`, `OPENBAO__SMB__SMB_LOGIN`, `OPENBAO__SMB__SMB_PASSWORD` | пусто | шара горячих папок; обязательны только при `SCAN_HOTFOLDER_MODE=smb`; `SMB_PATH` — `share/dir` или полный UNC |
|
||
| `NATS_ENABLED` | `true` | `false` — воркер не запускается, подключения к NATS нет (`/healthcheck.nats = disabled`) |
|
||
| `PUBLISH_RETRIES` | `3` | повторы публикации ack/результата после первой попытки (всего до 4) |
|
||
| `DONE_CACHE_SIZE` | `1000` | размер LRU обработанных `job_id` (дедупликация) |
|
||
| `SCAN_HOTFOLDER_MODE` | `local` | `local` или `smb` |
|
||
| `SCAN_HOTFOLDER_PATH` | пусто | корень горячей папки в режиме `local` (обязателен, каталог должен существовать) |
|
||
| `SCAN_INPUT_DIR` | `Input` | «Входящие» относительно корня |
|
||
| `SCAN_OUTPUT_DIR` | `Output` | «Исходящие» относительно корня |
|
||
| `SCAN_POLL_SECONDS` | `5` | период опроса «Исходящих», секунды |
|
||
| `SCAN_SETTLE_SECONDS` | `2` | набор `*.json` в `Output/<job_id>/` готов, когда имена и размеры не менялись столько секунд, считая от первого опроса, увидевшего окончательный набор (>= 0; `0` — сразу) |
|
||
| `SCAN_TIMEOUT_SECONDS` | `1200` | потолок ожидания ответа ИС, секунды (дополнительно к `reply_deadline`; 20 мин — в паре со сроком ответа OCR ядра) |
|
||
| `SCAN_MAX_INFLIGHT` | `4` | одновременно обрабатываемых заданий |
|
||
| `SCAN_CLEANUP` | `false` | очистка горячей папки после чтения набора ответа: удаляет всё содержимое `Output/<job_id>/` и каталог, `Input/<job_id>.<ext>` (и `.done`); `false` — ничего не удаляется; см. «Очистка горячей папки» |
|
||
| `SCAN_RULES_DIR` | `rules` | каталог справочников нормализатора (`statement_lines.yaml`, `ocr_lookalikes.yaml`, `statement_labels.yaml`), путь от текущего каталога (`api/`); битый справочник — сервис не стартует |
|
||
| `SERVICE_NAME` | `scan-gateway` | `service.name` в конверте |
|
||
| `VERSION` | `1.0.0` | версия сервиса (`/healthcheck`, `service.version`) |
|
||
| `ROOT_PATH` | `/` | префикс FastAPI за прокси |
|
||
| `SERVER__HOST`, `SERVER__PORT` | `0.0.0.0`, `8000` | адрес HTTP-сервера `run_server.py` |
|
||
| `DEBUG` | `false` | флаг шаблона платформы; поведение шлюза не меняет |
|
||
| `ENV_FILE` | `.env` | какой файл окружения читать |
|
||
|
||
Эмулятор (`python -m src.scan_emulator`) читает свои переменные и не использует настройки шлюза (в его контейнере нет
|
||
MinIO и NATS); те же значения подхватываются из `ENV_FILE`:
|
||
|
||
| Переменная | По умолчанию | Назначение |
|
||
|---|---|---|
|
||
| `SCAN_HOTFOLDER_PATH` | — (обязательна) | корень горячей папки; должен существовать, иначе код выхода 2 |
|
||
| `SCAN_INPUT_DIR`, `SCAN_OUTPUT_DIR` | `Input`, `Output` | подкаталоги корня (как у шлюза) |
|
||
| `SCAN_TMP_DIR` | `Tmp` | служебный каталог эмулятора: в нём собирается набор ответа перед rename в `Output/<uuid>/` (шлюз эту переменную не читает) |
|
||
| `SCAN_FIXTURES_DIR` | `../../fixtures/ocr` | каталог эталонных пар: исходник (PDF и др.) и ответы ИС (`aep_N.json`, `aep_N_<суффикс>.json`); пара ищется по sha256 содержимого входящего файла; путь от `api/` |
|
||
| `SCAN_EMULATOR_DEFAULT` | пусто | основа имени набора (например, `aep_6`), который отдаётся при промахе по хешу; пусто или набора нет — ответ-ошибка `INVALID_DOCUMENT` |
|
||
| `SCAN_EMULATOR_DELAY` | `2` | задержка перед выдачей ответа, секунды (>= 0) |
|
||
| `SCAN_EMULATOR_POLL_SECONDS` | `1` | период просмотра «Входящих», секунды (> 0) |
|
||
|
||
## Коды ошибок
|
||
|
||
Итоговый конверт `error` содержит `error.code` и `error.message`. Нормализатор возвращает коды ответа ИС и
|
||
`INVALID_FORMAT`/`RECOGNITION_FAILED` (ни одной таблицы), остальные коды ставит воркер (`api/src/core/worker.py`).
|
||
Код `NOT_IMPLEMENTED` (заглушка Среза 0) сервис больше не публикует: есть успешный файл — документы и `done` /
|
||
`needs_check`.
|
||
|
||
| Код | Когда | Ack |
|
||
|---|---|---|
|
||
| `SCHEMA_MISMATCH` | тело не JSON / не по схеме / MAJOR версии ≠ 1 / `pipeline` ≠ `image`. Конверт публикуется, только если в теле есть корректные `job_id` и `file_id`, иначе запрос пишется в лог и отбрасывается | нет |
|
||
| `FILE_READ_ERROR` | объект S3 не прочитан; `s3.bucket` не совпадает с бакетом сервиса | да |
|
||
| `RECOGNITION_FAILED` | у `original_name` нет допустимого расширения (файл в ИС не передаётся; `<job_id>.<ext>`, 1–16 букв/цифр ASCII); не записан файл во «Входящие» (горячая папка недоступна); «Исходящие» не читались ни разу до дедлайна; **ни одной таблицы не разобрано** (успешные файлы набора не дали ни одного документа; сообщение «ни одной таблицы не разобрано», `raw_s3_key` заполнен, причины — в `metrics.warnings`) | да |
|
||
| `TIMEOUT` | ИС не ответила до `min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)`; `reply_deadline` истёк до отправки файла | да |
|
||
| `INVALID_FORMAT` | все файлы ответа ошибочны, и у первого ошибочного файла не разобрался JSON (сырой ответ сохранён, `raw_s3_key` указан) или корень ответа не объект; склейка нарушена или пуста | да |
|
||
| код ошибки ИС | **все** файлы ответа ошибочны (`error.code` непустой): код первого ошибочного файла по имени пробрасывается, если он входит в `ErrorCode` (например, `INVALID_DOCUMENT`); каждый ошибочный файл — строка `metrics.warnings` | да |
|
||
| `UNKNOWN_ERROR` | сырой ответ или результат не записан в S3; нормализатор упал (исключение вне разбора таблицы: исключение внутри таблицы — предупреждение `BACKEND_PARSING_ERROR`); неизвестный код ИС (в том числе `error.code` не строка: число, список, `true`); непредвиденная ошибка | да |
|
||
|
||
Файл ответа считается ошибочным, если его `error.code` непустой, файл не разобрался как JSON (`parse_error`) либо корень
|
||
ответа не объект (`INVALID_FORMAT`).
|
||
`metrics.warnings` итогового конверта содержит по строке `<имя файла>: <код>: <сообщение>` на каждый ошибочный файл (и когда
|
||
есть успешный файл, и когда ошибочны все файлы) вперемешку с предупреждениями нормализации успешных файлов (см.
|
||
«Нормализация»), в порядке файлов набора по имени; при `done` / `needs_check` в `metrics` есть и `rows`. Если
|
||
предупреждений нет, `warnings` опускается.
|
||
|
||
## Контракт NATS (кратко)
|
||
|
||
Схемы и примеры — `../docs/contracts/`; их байт-в-байт копии лежат в `api/contracts/` (идентичность проверяет unit-тест
|
||
`api/tests/unit/test_contract.py`, входит в `make check`), pydantic-модели — `api/src/contract/`.
|
||
|
||
- Запрос: subject `files.img_uploaded` (по умолчанию; `OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT`), подписка в queue group `scan-gateway`, `Nats-Msg-Id = <job_id>:<attempt>`.
|
||
- Ответы — subject `files.tabular_parsed` (по умолчанию; `OPENBAO__NATS__NATS_RESULT_SUBJECT`): ack `status=processing`, `Nats-Msg-Id = ack:<job_id>:<attempt>`; итог
|
||
`status = done | needs_check | error`, `Nats-Msg-Id = result:<job_id>:<attempt>`. `service.name = "scan-gateway"`.
|
||
- Ключи S3: исходник — из запроса (`s3.key`), сырой ответ (склейка файлов ИС) — `raw/{job_id}.json`, результат —
|
||
`results/{file_id}/{job_id}/result.json`. Бакет один.
|
||
- NATS без JetStream: переподключение и повторная подписка — на `nats-py`, ack и повторы публикации — на уровне
|
||
приложения (`api/src/core/nats.py`).
|
||
|
||
## Запуск
|
||
|
||
Требуются Docker с compose v2, `uv`, Python 3.11. Все команды `make` — из корня репозитория; backend-команды
|
||
рецепты выполняют в `api/`. Рядом в workspace должны лежать `../docs` (compose, контракт) и `../fixtures`.
|
||
|
||
### Разработка
|
||
|
||
```bash
|
||
make up # NATS, MinIO, PostgreSQL: compose-проект arm-ka из ../docs/dev
|
||
make venv # api/.venv (Python 3.11), зависимости без raisa_*
|
||
cp api/.env.example api/.env # значения совпадают с ../docs/dev (NATS 4222, MinIO 9000, бакет arm-ka)
|
||
mkdir -p api/.hotfolder # корень горячей папки: сам не создаётся
|
||
cd api && .venv/bin/python run_server.py # сервис на :8000
|
||
make emulator # в другом терминале: эмулятор ИС Сканирование
|
||
curl -s localhost:8000/healthcheck
|
||
```
|
||
|
||
Целей `down`/`stop` в `Makefile` нет намеренно: compose-проект `arm-ka` общий для сервисов workspace.
|
||
Контейнера `scan-emulator` в compose пока нет (его добавит репозиторий `docs` позже), поэтому эмулятор запускается
|
||
локально командой `make emulator`.
|
||
|
||
### Полный круг против compose и эмулятора
|
||
|
||
Исполняемый пример — `api/tests/integration/test_roundtrip.py` (поднимает приложение и эмулятор в процессе теста,
|
||
публикует запрос, проверяет ack, итоговый конверт, объекты S3). Вручную те же шаги:
|
||
|
||
1. Выполнить «Разработку» выше: сервис и эмулятор работают, `/healthcheck` отдаёт `nats = ok`, `hotfolder = ok`.
|
||
2. Положить файл в MinIO (консоль http://localhost:9001 или `mc`) в бакет `arm-ka`, например
|
||
`uploads/<file_id>/<job_id>/aep_3.pdf`. Эмулятор узнаёт файл **по содержимому** (sha256), а не по имени: нужен
|
||
PDF из `../fixtures/ocr` (`aep_3.pdf` -> набор `aep_3.json` + `aep_3_ofr.json`, `aep_6.pdf` -> `aep_6.json`,
|
||
`aep_5.PDF` -> `aep_5.json`); любое другое содержимое даёт `INVALID_DOCUMENT`, если не задан `SCAN_EMULATOR_DEFAULT`.
|
||
`original_name` в запросе определяет только расширение входящего файла.
|
||
3. Подписаться на `files.tabular_parsed` любым клиентом NATS.
|
||
4. Опубликовать в `files.img_uploaded` тело по образцу `api/contracts/examples/request-image.json`
|
||
(свои `job_id` и `file_id` — UUID, `original_name`, `s3.key`, `reply_deadline` в будущем, `attempt` = 1) с заголовком
|
||
`Nats-Msg-Id: <job_id>:1`.
|
||
5. Ожидать: ack `processing`, через `SCAN_EMULATOR_DELAY` (+ до `SCAN_SETTLE_SECONDS + SCAN_POLL_SECONDS` после первого опроса, увидевшего набор) —
|
||
итоговый конверт:
|
||
- `aep_3.pdf` — `needs_check`, `result_s3` с двумя документами (`BALANCE` 28 строк и `INCOME` 13 строк после
|
||
удаления повторов кодов), `metrics.warnings` с предупреждениями нормализации и строкой
|
||
`<job_id>_ofr.json: INVALID_DOCUMENT: ...` (второй файл набора — ответ-ошибка);
|
||
- `aep_6.pdf` — `needs_check`, два документа (`BALANCE`, `INCOME`), в предупреждениях «редакция fsbu4 не заполнена»;
|
||
- `aep_5.PDF` (и `aep_8.pdf`) — `error`, `INVALID_DOCUMENT` с сообщением ИС (ответы-ошибки);
|
||
- неизвестное содержимое — `error`, `INVALID_DOCUMENT` эмулятора.
|
||
В бакете появится `raw/<job_id>.json` (склейка всех файлов набора), `raw_s3_key` указан в конверте; для
|
||
`done` / `needs_check` — ещё `results/<file_id>/<job_id>/result.json`. В горячей папке: при `SCAN_CLEANUP=false` —
|
||
`Input/<job_id>.pdf.done` и `Output/<job_id>/`, при `true` — пусто.
|
||
|
||
### Проверки
|
||
|
||
```bash
|
||
make check # lint (ruff check, ruff format --check, mypy src) + test (pytest -m "not integration")
|
||
make test-integration # pytest -m integration: нужны NATS и MinIO (make up)
|
||
make docker-build # docker build -t scan-gateway:dev . (образ без Nexus)
|
||
make check-full # check + test-integration + docker-build; перед PR
|
||
make golden-update # пересобрать эталоны нормализатора ../fixtures/ocr/expected_normalize/ (см. «Нормализация»)
|
||
```
|
||
|
||
Тесты запускаются из `api/`: `.env.test` подключается через `ENV_FILE=.env.test` в `api/pytest.ini` и разрешается
|
||
относительно текущего каталога (рецепты `make` это делают сами). Маркеры: `integration` — нужна инфраструктура,
|
||
исключается из `make test`; `fixtures` — нужны реальные данные из `../fixtures` (нет репозитория — тесты пропускаются).
|
||
Тесты: `api/tests/unit/`, `api/tests/integration/`. Интеграционные тесты делят NATS с другими сервисами workspace:
|
||
если в нём работает другой экземпляр `scan-gateway`, он может забрать запрос теста из queue group.
|
||
Шаги `make check-full` идут последовательно и прерываются на первой ошибке; тесты в образе не запускаются.
|
||
|
||
## `/healthcheck`
|
||
|
||
`GET /healthcheck` всегда отвечает 200; состояние — в полях:
|
||
|
||
| Поле | Значения |
|
||
|---|---|
|
||
| `uptime` | время старта процесса |
|
||
| `version` | `VERSION` |
|
||
| `nats` | `ok` — соединение установлено; `down` — подключение идёт, переподключение, сбой; `disabled` — `NATS_ENABLED=false` |
|
||
| `hotfolder` | `ok` — `HotFolder.healthy()` (корень существует и доступен); `down` — нет, проверка не уложилась в 2 с или упала |
|
||
| `subjects` | `{image_request, result}` — subject'ы NATS из настроек; отдаётся при любом состоянии NATS; JWT/seed в ответе нет |
|
||
|
||
Подключение к NATS идёт в фоне: HTTP отвечает сразу (`nats = down`), подписка появляется после подключения.
|
||
Результат проверки горячей папки кэшируется на 5 с.
|
||
|
||
## Docker-образ
|
||
|
||
Один образ (`Dockerfile`, `python:3.11-slim`, контекст сборки — корень репозитория, `docker build -t scan-gateway .`)
|
||
для шлюза и для эмулятора. По умолчанию запускается шлюз (`python run_server.py`, порт 8000); эмулятор — тем же образом
|
||
с `command: python -m src.scan_emulator`. Пакеты `raisa_*` в образ не ставятся. `api/.env` в образ не попадает (см.
|
||
`.dockerignore`): окружение приходит из compose; шлюзу и эмулятору нужен общий том с горячей папкой по пути
|
||
`SCAN_HOTFOLDER_PATH`, эмулятору — каталог фикстур по пути `SCAN_FIXTURES_DIR`.
|
||
|
||
## Известные ограничения
|
||
|
||
Нормализатор (Срез 3a; решения — таблица «Decisions made without discussion» в
|
||
`openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/design.md`):
|
||
|
||
- Редакция `fsbu4` (отчётность с 2025 года) пока пуста: имена строк документов 2025 года берутся из OCR, предупреждение
|
||
«редакция fsbu4 не заполнена», статус `needs_check`. Состав редакции — данные владельца (см. «Справочники»).
|
||
- Строки, чей код не целое число после обрезки пробелов (например, `1150 :`), разбор относит к классу `HEADER` и
|
||
пропускает (перенос `_classify_row`; раскладка колонок при этом **не** переписывается — её меняет только строка с
|
||
ячейкой «Код…»). Строки с пустым кодом тоже пропускаются. В документ они не попадают и остаются в `raw/`, но
|
||
таблица получает предупреждения `строк с нечисловым кодом пропущено: <N>` и `строк без кода с суммами пропущено: <N>`
|
||
(последнее — только если в строке есть сумма), то есть `needs_check` (решение №29). Сами строки не восстанавливаются
|
||
(OQ-149): в реальных сканах так теряются, например, «БАЛАНС» и «Выручка» с пустым кодом.
|
||
- Склеенная строка ИС — несколько целых кодов в ячейке через перевод строки (`1500\n1700`, ББ РБ и Счастливочной за
|
||
1 кв. 2025) — до классификации разлепляется на столько строк, сколько кодов, если в каждой непустой ячейке значений
|
||
столько же частей (наименование делится так же, иначе остаётся целым); иначе строка пропускается как нечисловой код.
|
||
Предупреждение `строк с несколькими кодами разлеплено: <N>`, статус `needs_check` (OQ-156, решение владельца 08.10.2026;
|
||
расхождение с источником `_classify_row`).
|
||
- Скобка, распознанная как `}` или `]` (`(7744536}`), знак минус не даёт: сумма остаётся положительной с
|
||
`is_recognized = false` (решение №12, дословный перенос `_parse_amount`; расширение набора закрывающих скобок — после
|
||
проверки на стенде, вынесено в открытые вопросы docs).
|
||
- Длинное тире «—» / «–» в ячейке значения (прочерк) при `confidence = 100` даёт `is_recognized = false` (дословный
|
||
`parse_amount` вырезает тире, решение №20); дефис «-» — `true`. Лишний `needs_check` на формах с длинным тире.
|
||
- Замена двойников (`fix_lookalikes`) применяется только к датам (дата отчёта, подписи колонок); код строки с латинской
|
||
`O` вместо нуля отбрасывается как не-целое (решение №22). Суммы разбираются `parse_amount` без замены символов.
|
||
- Повтор кода строки в таблице: остаётся первая строка, вторая теряется в документе (решение №15).
|
||
- `checks` в документах нет: соотношения §7 считает ядро (Срез 3b).
|
||
- Ошибочный файл набора при успешных даёт `needs_check` (`done` невозможен, решение №28); предупреждение
|
||
`<имя>: <код>: ...` остаётся в `metrics.warnings`.
|
||
- Расшифровки без кода (OQ-36) и склейка одной формы из нескольких таблиц (OQ-77) не обрабатываются: каждая таблица —
|
||
свой документ.
|
||
|
||
Транспорт и эмулятор:
|
||
|
||
- Режим `smb` покрыт unit-тестами с подменённым `smbclient`; на реальной шаре ИС Сканирование не проверялся. Фактические
|
||
имена каталогов и путь шары на стенде неизвестны — они настраиваются, а не зашиты в код. Права на удаление на шаре
|
||
контура не подтверждены — по умолчанию `SCAN_CLEANUP=false`.
|
||
- Повторной отправки в ИС нет (OQ-15); падение шлюза после ack теряет задание до `reply_deadline` ядра.
|
||
- Зависший вызов SMB (`poll`/`put`, до ~60 с по умолчанию `smbclient`) дедлайном воркера не прерывается: вызов идёт
|
||
в потоке без собственного таймаута, поэтому `TIMEOUT` может прийти позже дедлайна на это время.
|
||
- Недописанный набор ответа ИС: защита — settle (`SCAN_SETTLE_SECONDS`), а не разбор содержимого. Если ИС дописывает
|
||
файл дольше settle, будет принят неполный набор; файл, не разобравшийся как JSON, после склейки не перечитывается:
|
||
`response = null`, `parse_error` в `raw/`, ошибка этого файла (`INVALID_FORMAT`) — в `metrics.warnings`.
|
||
- Запись сразу в `Input` (change `hotfolder-direct-write`): защита от чтения недописанного файла — только монопольное
|
||
открытие `smbclient` на время записи (ASSUMPTION(OQ-79), как в старом АРМ). Если ИС всё же возьмёт файл во время
|
||
записи, распознавание даст ошибку; откат — вернуть запись через временный файл.
|
||
- Эмулятор: повреждённая фикстура (не JSON, не UTF-8 или корень не объект) не останавливает его — на её месте в наборе
|
||
он кладёт ответ `INVALID_DOCUMENT` с сообщением о повреждённой фикстуре и пишет ошибку в лог. Индекс хешей
|
||
пересчитывается при изменении списка файлов каталога фикстур: изменённый, но не переименованный PDF не
|
||
переиндексируется (решение №9).
|
||
- Реальные фикстуры: `aep_3.json` (баланс и ОФР), `aep_6.json` — успешные ответы, `aep_3_ofr.json`, `aep_5.json`,
|
||
`aep_8_form4.json` — ответы-ошибки (`INVALID_DOCUMENT`; `data_ocr` — объект с пустыми полями и `table_structure: []`).
|
||
- Контейнера `scan-emulator` в compose пока нет.
|