374 lines
43 KiB
Markdown
374 lines
43 KiB
Markdown
# image-recognition-gateway Specification
|
||
|
||
## Purpose
|
||
Адаптер внешней ИС Сканирование (Content AI) для PDF/PNG/JPG: принимает и подтверждает задание из NATS без JetStream,
|
||
передаёт файл через горячие папки, ждёт ответ не дольше таймаута, сохраняет сырой ответ в S3 и отвечает ядру по
|
||
контракту v1.0. Без БД и фронта.
|
||
|
||
## Requirements
|
||
|
||
### Requirement: Сервис не зависит от БД и фронта
|
||
Репозиторий MUST NOT содержать `web/`, `alembic/`, `src/db/`, `src/models/`, `src/repositories/`; зависимости `SQLAlchemy`,
|
||
`asyncpg`, `alembic`, `raisa_fastapi_protected_api` MUST NOT присутствовать; `smbprotocol` SHALL остаться. Для старта
|
||
SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFOLDER_MODE=local`, `SCAN_HOTFOLDER_PATH`.
|
||
|
||
#### Scenario: Старт в режиме локальных папок
|
||
- **WHEN** сервис запущен с `SCAN_HOTFOLDER_MODE=local` и существующим каталогом `SCAN_HOTFOLDER_PATH`
|
||
- **THEN** `GET /healthcheck` → 200 с полями `uptime`, `version`, `nats`, `hotfolder = ok`; `OPENBAO__SMB__*` не требуются
|
||
|
||
### Requirement: Подтверждение запроса до обработки
|
||
Сервис SHALL быть подписан на `files.img_uploaded` в queue group `scan-gateway`. Получив запрос, проходящий схему,
|
||
сервис SHALL до чтения файла из S3 опубликовать в `files.tabular_parsed` конверт `status = processing`
|
||
(`service.name = "scan-gateway"`, `Nats-Msg-Id = ack:<job_id>:<attempt>`). Повторный запрос с `job_id`, который уже
|
||
обрабатывается или обработан этим процессом, SHALL получить повторный ack, и второй файл во входящий каталог MUST NOT записываться.
|
||
|
||
#### Scenario: Ack раньше файла
|
||
- **WHEN** получен запрос для `aep_5.pdf`
|
||
- **THEN** конверт `processing` опубликован раньше, чем файл появился во входящем каталоге
|
||
|
||
#### Scenario: Повторный запрос
|
||
- **WHEN** тот же запрос доставлен повторно во время ожидания ответа ИС
|
||
- **THEN** публикуется ещё один ack, во входящем каталоге остаётся один файл `<job_id>.pdf`
|
||
|
||
### Requirement: Передача файла в ИС Сканирование через горячие папки
|
||
После подтверждения сервис SHALL прочитать исходник из S3 (`s3.bucket/s3.key`) и записать его во входящий каталог как
|
||
`<job_id>.<ext>`, где `<ext>` — расширение `original_name` запроса в нижнем регистре; запрос без расширения SHALL
|
||
завершаться конвертом `RECOGNITION_FAILED` без записи в горячую папку. Файл SHALL записываться сразу во входящий каталог
|
||
открытием на запись с усечением, как в старом АРМ (решение владельца 09.10.2026): временных файлов, служебного каталога
|
||
и переименования нет; если `<Input>/<job_id>.<ext>` уже существует, он SHALL быть перезаписан содержимым объекта S3.
|
||
Затем сервис SHALL опрашивать исходящий каталог `<SCAN_OUTPUT_DIR>/<job_id>/` каждые `SCAN_POLL_SECONDS` до
|
||
`min(reply_deadline, старт + SCAN_TIMEOUT_SECONDS)` (`SCAN_TIMEOUT_SECONDS` по умолчанию 1200). ИС кладёт в этот каталог
|
||
все распознанные документы задания — по одному `*.json` на документ, набор появляется целиком и больше не меняется;
|
||
пустой или отсутствующий каталог означает ожидание. Ответ SHALL считаться готовым, когда в каталоге есть хотя бы один
|
||
`*.json`, а набор имён и размеров `*.json` не менялся `SCAN_SETTLE_SECONDS` (по умолчанию 2); готовый набор SHALL
|
||
включать **все** `*.json` каталога по возрастанию имени, остальные файлы игнорируются.
|
||
|
||
#### Scenario: Файл положен во входящий каталог
|
||
- **WHEN** получен запрос для `aep_5.PDF` с `job_id = 3f1c…`
|
||
- **THEN** во входящем каталоге появляется `3f1c….pdf`, байт в байт равный объекту S3; каталог `Tmp/` не создаётся, временных файлов нет
|
||
|
||
#### Scenario: Назначение уже существует
|
||
- **WHEN** запрос для `aep_5.pdf` с `job_id = 3f1c…` обрабатывается, а `Input/3f1c….pdf` уже лежит с другим содержимым (задание повторено ядром после потери ack в другом процессе)
|
||
- **THEN** `Input/3f1c….pdf` перезаписан и байт в байт равен объекту S3, задание продолжается опросом `Output/3f1c…/`, конверт `RECOGNITION_FAILED` не публикуется
|
||
|
||
#### Scenario: Запрос без расширения
|
||
- **WHEN** получен запрос с `original_name = "scan"` без точки
|
||
- **THEN** опубликован конверт `error`, `error.code = RECOGNITION_FAILED`, сообщение о расширении; во входящем каталоге файла нет
|
||
|
||
#### Scenario: Ответ из одного файла
|
||
- **WHEN** в `Output/3f1c…/` появляется один `*.json` и набор не меняется `SCAN_SETTLE_SECONDS`
|
||
- **THEN** опрос прекращается не позднее чем через `SCAN_SETTLE_SECONDS` + `SCAN_POLL_SECONDS` после первого опроса, увидевшего окончательный набор (отсчёт стабилизации — от этого опроса, а не от времени изменения файла), ответ из одного файла передаётся дальше по конвейеру
|
||
|
||
#### Scenario: Ответ из нескольких файлов
|
||
- **WHEN** в `Output/3f1c…/` лежат `3f1c….json` и `3f1c…_ofr.json`, посторонний файл `readme.txt`, и набор не меняется `SCAN_SETTLE_SECONDS`
|
||
- **THEN** дальше по конвейеру передаются оба `*.json` по возрастанию имени, `readme.txt` игнорируется
|
||
|
||
#### Scenario: Набор ещё пишется
|
||
- **WHEN** размер файла или число файлов в `Output/3f1c…/` менялось менее `SCAN_SETTLE_SECONDS` назад
|
||
- **THEN** ответ не считается готовым, опрос продолжается до дедлайна
|
||
|
||
#### Scenario: Ошибка посреди записи
|
||
- **WHEN** запись `Input/3f1c….pdf` прервалась ошибкой ввода-вывода
|
||
- **THEN** опубликован конверт `RECOGNITION_FAILED`; недописанный файл удалён только при `SCAN_CLEANUP=true`, при `false` остаётся на месте
|
||
|
||
### Requirement: Сырой ответ сохраняется в S3 до нормализации
|
||
Все файлы ответа ИС SHALL быть склеены в один объект `{"job_id": "<job_id>", "files": [{"name": "<имя файла>",
|
||
"response": <разобранный json> | null, "parse_error": "<текст>" | null}, ...]}` (файлы по возрастанию имени) и записаны
|
||
в `raw/{job_id}.json` того же бакета **до** вызова нормализатора; файл, не разобранный как JSON, SHALL получить
|
||
`response = null` и заполненный `parse_error` и MUST NOT прерывать задание. Ключ SHALL передаваться в конверте результата
|
||
полем `raw_s3_key` (одна строка `raw/{job_id}.json`); в БД объект не регистрируется (ядро его не создаёт).
|
||
|
||
#### Scenario: Нормализатор упал
|
||
- **WHEN** ответ ИС получен, а нормализатор бросил исключение
|
||
- **THEN** объект `raw/{job_id}.json` существует в S3, конверт содержит `raw_s3_key` и `error.code = UNKNOWN_ERROR`
|
||
|
||
#### Scenario: Склеенный сырой ответ
|
||
- **WHEN** ИС положила в `Output/<job_id>/` файлы `<job_id>.json` и `<job_id>_ofr.json`
|
||
- **THEN** `raw/{job_id}.json` содержит `job_id` и `files` из двух элементов (`<job_id>.json`, затем `<job_id>_ofr.json`), у каждого `response` — разобранный ответ, `parse_error = null`
|
||
|
||
#### Scenario: Файл не JSON
|
||
- **WHEN** один из файлов ответа не разбирается как JSON
|
||
- **THEN** в `files` для него `response = null`, `parse_error` непустой; остальные файлы разобраны, задание не падает
|
||
|
||
### Requirement: Результат по контракту
|
||
Сервис SHALL публиковать в `files.tabular_parsed` конверт по `recognition-result-message.schema.json` с
|
||
`service.name = "scan-gateway"`, `Nats-Msg-Id = result:<job_id>:<attempt>`; при `status ∈ {done, needs_check}` — файл
|
||
результата (`recognition-result.schema.json`, `schema_version = "1.0"` — `CONTRACT_VERSION` сервиса, решение №14; схема допускает любой MINOR) в `results/{file_id}/{job_id}/result.json` и `result_s3`;
|
||
при неудаче публикации SHALL повторить её до трёх раз. Нормализатор получает склеенный ответ и SHALL считать файл
|
||
успешным, если `error.code == ""` (поле `error` реального успешного ответа — `{"code": "", "message": ""}`, а не `null`;
|
||
отсутствующий или `null` `error` ошибкой не считается), и ошибочным, если `error.code` непуст, `parse_error` не `null`
|
||
либо корень ответа файла не является объектом JSON (ошибка этого файла — `INVALID_FORMAT`); успех MUST NOT определяться
|
||
проверкой «`error` не `null`». Если ошибочны **все** файлы — `status = error`, `error.code` и `error.message` берутся у
|
||
первого ошибочного файла по имени (для `parse_error` и не-объекта — `INVALID_FORMAT`); если код ИС этого файла не
|
||
входит в перечень кодов контракта, `error.code = UNKNOWN_ERROR`, а исходный код SHALL быть указан в `error.message`.
|
||
Если есть хотя бы один успешный файл — документы собираются из успешных файлов по требованию «Нормализация ответа ИС»,
|
||
а статус определяется по требованию «Статус результата нормализации». В обоих случаях `metrics.warnings` SHALL
|
||
содержать по одной строке `<имя файла>: <код>: <сообщение>` на каждый ошибочный файл, где `<код>` — исходный код ИС как
|
||
получен (для `parse_error` и не-объекта — `INVALID_FORMAT`); если предупреждений нет, поле `warnings` SHALL быть опущено.
|
||
|
||
#### Scenario: Успешный ответ ИС в Срезе 0
|
||
- **WHEN** ответ из одного файла с `error = {"code": "", "message": ""}` и непустым `data_ocr` (по образцу `aep_6.json`)
|
||
- **THEN** начиная со Среза 3a конверт больше не `NOT_IMPLEMENTED`: поведение — по сценарию «Успешный ответ ИС» ниже
|
||
|
||
#### Scenario: Успешный ответ ИС
|
||
- **WHEN** ответ из одного файла с `error = {"code": "", "message": ""}` и непустым `data_ocr` (`aep_6.json`)
|
||
- **THEN** конверт `status = needs_check`, `raw_s3_key = raw/<job_id>.json`, `result_s3` указывает на файл результата с двумя документами (`BALANCE`, `INCOME`)
|
||
|
||
#### Scenario: Ошибка ИС
|
||
- **WHEN** единственный файл ответа `<job_id>.json` содержит `error.code = INVALID_DOCUMENT`
|
||
- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT`, `error.message` из ответа, `raw_s3_key` заполнен, `metrics.warnings` содержит одну строку `<job_id>.json: INVALID_DOCUMENT: <сообщение>`
|
||
|
||
#### Scenario: Ошибка одного из документов
|
||
- **WHEN** ответ состоит из `<job_id>.json` (успех, `aep_3.json`) и `<job_id>_ofr.json` (`INVALID_DOCUMENT`, `aep_3_ofr.json`)
|
||
- **THEN** конверт `status = needs_check`, документы собраны из `<job_id>.json`, `metrics.warnings` содержит строку `<job_id>_ofr.json: INVALID_DOCUMENT: <сообщение>` и предупреждения нормализации
|
||
|
||
#### Scenario: Все документы ошибочны
|
||
- **WHEN** оба файла ответа ошибочны (первый по имени — `INVALID_DOCUMENT`, второй — не JSON)
|
||
- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT` (код первого ошибочного файла по имени), `metrics.warnings` содержит две строки; файл не JSON в warnings несёт код `INVALID_FORMAT`
|
||
|
||
#### Scenario: Все файлы не JSON
|
||
- **WHEN** единственный файл ответа не разбирается как JSON
|
||
- **THEN** конверт `status = error`, `error.code = INVALID_FORMAT`, `metrics.warnings` содержит одну строку `<job_id>.json: INVALID_FORMAT: <сообщение разбора>`
|
||
|
||
#### Scenario: Корень ответа не объект
|
||
- **WHEN** файл ответа — корректный JSON, но корень — массив
|
||
- **THEN** этот файл ошибочен с кодом `INVALID_FORMAT`; остальные файлы оцениваются по общим правилам
|
||
|
||
#### Scenario: Код ошибки ИС вне перечня контракта
|
||
- **WHEN** единственный файл ответа содержит `error.code = SOME_NEW_CODE`
|
||
- **THEN** конверт `status = error`, `error.code = UNKNOWN_ERROR`, `error.message` содержит `SOME_NEW_CODE`, строка в `metrics.warnings` — `<job_id>.json: SOME_NEW_CODE: <сообщение>`
|
||
|
||
### Requirement: Таймаут ожидания ответа
|
||
Если готовый набор ответа (см. «Передача файла…») не появился до дедлайна, сервис SHALL опубликовать конверт `status = error`, `error.code = TIMEOUT`;
|
||
повторная отправка файла в ИС MUST NOT выполняться.
|
||
|
||
#### Scenario: ИС молчит
|
||
- **WHEN** запрос с `reply_deadline` через 10 с, ответ не появляется
|
||
- **THEN** через ≤ 15 с опубликован конверт `TIMEOUT`, входящий файл остаётся в каталоге
|
||
|
||
#### Scenario: Набор не успел настояться до дедлайна
|
||
- **WHEN** в `Output/<job_id>/` есть `*.json`, но набор имён или размеров менялся позже, чем `SCAN_SETTLE_SECONDS` до дедлайна
|
||
- **THEN** ответ не считается готовым, опубликован конверт `TIMEOUT`, недостающие файлы не ожидаются
|
||
|
||
### Requirement: Ошибки транспорта превращаются в результат
|
||
Сервис SHALL отвечать конвертом `status = error` с кодом по таблице: запрос не по схеме или `MAJOR` ≠ 1 →
|
||
`SCHEMA_MISMATCH` (ack не публикуется); объект S3 не читается → `FILE_READ_ERROR`; запись во входящий каталог не
|
||
удалась или у исходника нет расширения → `RECOGNITION_FAILED`; файл ответа ИС не разбирается как JSON → `parse_error`
|
||
этого файла, а если ошибочны все файлы — `INVALID_FORMAT`; ни одной таблицы не разобрано нормализатором →
|
||
`RECOGNITION_FAILED`; иное исключение → `UNKNOWN_ERROR`. Ошибки очистки горячей папки MUST NOT влиять на конверт.
|
||
Во всех случаях конверт SHALL быть опубликован, процесс MUST NOT завершаться.
|
||
|
||
#### Scenario: Горячая папка недоступна
|
||
- **WHEN** каталог `SCAN_HOTFOLDER_PATH` не существует в момент обработки
|
||
- **THEN** конверт `RECOGNITION_FAILED` с сообщением о недоступности горячей папки; `/healthcheck.hotfolder = down`
|
||
|
||
### Requirement: Параллельная обработка ограничена
|
||
Сервис SHALL обрабатывать не более `SCAN_MAX_INFLIGHT` (по умолчанию 4) заданий одновременно; задания сверх лимита
|
||
SHALL ждать слот во внутренней очереди, их ack SHALL быть опубликован сразу при получении.
|
||
|
||
#### Scenario: Пять заданий подряд
|
||
- **WHEN** в очереди пять запросов при `SCAN_MAX_INFLIGHT = 4`
|
||
- **THEN** пять ack'ов опубликованы сразу, четыре файла появляются во входящем каталоге, пятый — после завершения любого из четырёх
|
||
|
||
### Requirement: Копия контракта идентична источнику
|
||
`api/contracts/*.schema.json` SHALL быть идентичны `../docs/contracts/`; pydantic-модели SHALL валидировать примеры docs;
|
||
проверка входит в `make check`.
|
||
|
||
#### Scenario: `make check`
|
||
- **WHEN** выполняются тесты контракта при доступном `../docs`
|
||
- **THEN** идентичность подтверждена, примеры валидны
|
||
|
||
### Requirement: Соединение с NATS без JetStream
|
||
При пустых `NATS_SEED`/`NATS_JWT` сервис SHALL подключаться без учётных данных, иначе — с nkeys/JWT из `OPENBAO__NATS__NATS_SEED`/`NATS_JWT` (значения — секреты); MUST NOT требовать
|
||
stream'ов и consumer'ов JetStream; при разрыве SHALL переподключаться и восстанавливать подписку; `/healthcheck.nats`
|
||
SHALL отражать состояние.
|
||
|
||
#### Scenario: NATS перезапущен
|
||
- **WHEN** контейнер NATS перезапущен во время работы сервиса
|
||
- **THEN** в течение 20 с после старта NATS подписка восстановлена, `nats = ok`
|
||
|
||
### Requirement: Очистка горячей папки под флагом
|
||
При `SCAN_CLEANUP=true` после того, как готовый набор ответа прочитан и склеен в `raw/{job_id}.json` (независимо от
|
||
исхода нормализации), сервис SHALL удалить всё содержимое `<SCAN_OUTPUT_DIR>/<job_id>/` (json-файлы ответа и любые
|
||
прочие) и сам каталог `<job_id>`, входящий файл `<Input>/<job_id>.<ext>` и его вариант `<Input>/<job_id>.<ext>.done`,
|
||
если они существуют. При `SCAN_CLEANUP=false` (по умолчанию) сервис MUST NOT удалять ничего, включая недописанный при
|
||
ошибке записи входящий файл. При `TIMEOUT`, `FILE_READ_ERROR`, `RECOGNITION_FAILED` записи очистка MUST NOT выполняться
|
||
(при ошибке записи и `SCAN_CLEANUP=true` снимается только недописанный входящий файл). Ошибка удаления SHALL быть
|
||
записана в лог одной строкой на задание и MUST NOT менять конверт, `metrics.warnings` и `/healthcheck`.
|
||
|
||
#### Scenario: Очистка включена
|
||
- **WHEN** `SCAN_CLEANUP=true`, ответ на `3f1c….pdf` прочитан из `Output/3f1c…/`
|
||
- **THEN** после публикации конверта в горячей папке нет `Output/3f1c…/`, `Input/3f1c….pdf` и `Input/3f1c….pdf.done`
|
||
|
||
#### Scenario: Очистка выключена
|
||
- **WHEN** `SCAN_CLEANUP=false`, ответ прочитан
|
||
- **THEN** все файлы остаются на месте, сервис ничего не удаляет и не пишет в лог о очистке (решение №27)
|
||
|
||
#### Scenario: Нет прав на удаление
|
||
- **WHEN** `SCAN_CLEANUP=true`, удаление `Output/3f1c…/3f1c….json` завершается `PermissionError`
|
||
- **THEN** конверт опубликован с тем же статусом, что и без очистки; в логе одна строка об ошибке удаления; `/healthcheck.hotfolder = ok`
|
||
|
||
#### Scenario: Таймаут не чистит
|
||
- **WHEN** `SCAN_CLEANUP=true`, ответ не появился до дедлайна
|
||
- **THEN** опубликован `TIMEOUT`, входящий файл и каталог `Output/<job_id>/` (если есть) не тронуты
|
||
|
||
### Requirement: Нормализация ответа ИС в `statementDocument`
|
||
Для каждого успешного файла ответа нормализатор SHALL разобрать `data_ocr.table_structure[i]` в отдельный
|
||
`statementDocument` по §11 и §11.1 `05-domain-rules.md`:
|
||
- `document_type` — по нормализованному `label.form_name` таблицы через `statement_labels.yaml`; если `label` не
|
||
сопоставлен — по первой цифре кодов строк данных (`1` → `BALANCE`, `2` → `INCOME`, по большинству); иначе `OTHER`.
|
||
Если `label` и коды дают разный тип: при единодушных кодах (все коды данных одной группы) — тип по кодам,
|
||
предупреждение `тип по label (<label>) не совпал с кодами: взят <тип>` (#30); при смешанных кодах — тип по `label`,
|
||
предупреждение `тип по кодам (<тип>) не совпал с label (<label>)`. `header.form_name` в обоих случаях — текст `label`
|
||
как распознан. `locator = "file:<имя json>;table:<i>"`.
|
||
- Раскладка колонок — по строке с ячейкой «код…» (`build_layout`); повторная шапка перезаписывает раскладку; без шапки —
|
||
`infer_layout` по данным (столбец с наибольшим числом кодов из справочника, наименование — левее, значения — правее,
|
||
где суммы не реже мусора или столбец пуст), шапка синтезируется, предупреждение. Ни шапки, ни столбца кода — таблица
|
||
пропускается с предупреждением.
|
||
- `columns` — колонки значений раскладки по возрастанию `column_index` исходной таблицы, перенумерованные с 0;
|
||
`label` — текст ячейки шапки (`\n`/`\t` → пробел; у синтетической шапки — пустая строка), `period_date` — `parse_date`
|
||
текста (квартал/полугодие/N месяцев/диапазон → последний день последнего месяца), `null`, если не разобрался.
|
||
- `lines` — только строки данных с кодом: `line_code` — очищенный текст ячейки кода (`clean_value`); строка без кода и
|
||
строка, чей код после обрезки пробелов не является целым числом (`1150 :` — перенос `_classify_row`: считается повторным
|
||
заголовком), пропускаются. Повтор кода в документе: остаётся первая строка, предупреждение `код <код> повторяется:
|
||
оставлена первая строка`, документ `needs_check` (DBML: `financial_statement_line` уникален по `(meta_id, line_code)`;
|
||
так же поступает Excel-путь, §12 п. 5). `line_name` — из справочника редакции по коду; если кода
|
||
нет в справочниках — текст ячейки наименования. `line_code_confidence` — `confidence` ячейки кода, если очищенный текст
|
||
равен сырому и код есть в справочнике, иначе `min(confidence, 99)`; `line_name_confidence` — то же для словарного
|
||
имени, иначе `confidence` ячейки наименования.
|
||
- `values` — по одному на колонку документа (по позиции; если раскладка строки после повторной шапки даёт иное число колонок —
|
||
недостающие значения как «ячейки нет», лишние отбрасываются, одно предупреждение на таблицу «повторная шапка изменила
|
||
число колонок значений», решение №21; таблица без колонок значений — предупреждение «нет колонок значений», №25): `amount` — `parse_amount` (запятая → точка, скобки → минус, пробелы и прочие символы
|
||
отбрасываются, несколько точек — разделители тысяч; прочерк/пусто → `null`; сумма с более чем двумя знаками после запятой (`1,234`) не помещается в `Money` контракта — `amount = null`,
|
||
`string_value` — текст ячейки, `is_recognized = false`, решение №30); `string_value` — текст ячейки как
|
||
распознан (`\n`/`\t` → пробел, края обрезаны) **только если `amount = null`** и текст не прочерк (DBML: «как распознано,
|
||
если не число»), иначе `null`; `confidence` — ячейки (значения вне 0–100 и NaN приводятся к диапазону, №30); `is_recognized = confidence == 100 и разбор не отбрасывал символов`;
|
||
ячейки под колонкой нет → `amount = null`, `string_value = null`, `confidence = 0`, `is_recognized = false`.
|
||
- `header` — `form_name` из `label` таблицы; `organization_name` и `organization_name_confidence` из `data_ocr.organization`
|
||
(`\n` → пробел); `inn` — только цифры из `data_ocr.inn.form_name` (`null`, если цифр нет), `inn_confidence = confidence`,
|
||
если сырой текст состоял только из цифр, иначе `min(confidence, 99)`; `units` — `clean_value(units.form_name)`;
|
||
`report_date_text` — текст `date.form_name` с обрезанными краями, `report_date` — `parse_date` после `fix_lookalikes`
|
||
(`null`, если текст не дата); `okud`, `okpo`, `okopf`, `okfs`, `address` — не передаются (в проводе отсутствуют, у потребителя `null`; ИС их не отдаёт).
|
||
- `checks` MUST NOT присутствовать.
|
||
Строка с непустым нечисловым кодом (`1150 :`, `изо :`) пропускается, на таблицу выдаётся одно предупреждение
|
||
`строк с нечисловым кодом пропущено: <N>`; строка с пустым кодом и хотя бы одной разобранной суммой в колонках значений
|
||
пропускается с одним предупреждением на таблицу `строк без кода с суммами пропущено: <N>` (как в Excel-пути, OQ-141);
|
||
строки с пустым кодом без сумм (заголовки групп, «в том числе») — без предупреждения (решение №29).
|
||
Двойники букв и цифр (`fix_lookalikes`) SHALL применяться к тексту дат (дата отчёта и подписи колонок) только вплотную
|
||
к цифрам; `|` SHALL считаться разделителем клеток, если первая строка значения состоит из цифр, пробелов и знаков
|
||
пунктуации. Коды очищаются `clean_value`, суммы — `parse_amount`, ИНН — выделением цифр (как в проверенном на стенде
|
||
старом ядре, §11.1 п. 1; решение №22 `design.md`).
|
||
|
||
#### Scenario: Чистый скан с шапкой (`aep_6.json`)
|
||
- **WHEN** нормализуется `aep_6.json` (две таблицы: «Бухгалтерский баланс» 122 строки со строкой-шапкой «Наименование показателя / Код / На 30 июня 2025 г / На 31 декабря 2024 г. / На 31 декабря 2023 г.» и «Отчет о финансовых результатах» 49 строк)
|
||
- **THEN** два документа: `BALANCE` с `columns` `[{0, "На 30 июня 2025 г", 2025-06-30}, {1, "На 31 декабря 2024 г.", 2024-12-31}, {2, "На 31 декабря 2023 г.", 2023-12-31}]`, строка `1105` и `1110` присутствуют, строка «в том числе: …» без кода отсутствует; `INCOME` с колонками «За январь - июнь 2025 г.» → `2025-06-30` и «За январь - июнь 2024 г.» → `2024-06-30`, строка `2110` с `values[0].amount = 13070`; `header.units = "тыс. руб."`, `header.inn = null` (текст `+`), `header.report_date = null` (текст — название организации); статус `needs_check`
|
||
|
||
#### Scenario: Зашумлённый скан без шапки (`aep_3.json`)
|
||
- **WHEN** нормализуется `aep_3.json` (таблица «Бухгалтерский баланс» без строки-шапки, коды в `column_index = 1` с мусором вида `изо :`, ИНН `3|1 21 2| о! О' 0 0; 3: 51`, дата `2|0|2|4|`)
|
||
- **THEN** столбец кода выведен по данным, документ `BALANCE` с синтетической шапкой, `columns` без `period_date` и предупреждение «шапка синтезирована»; `header.inn = "3121200351"`, `inn_confidence < 100`; `header.report_date = 2024-12-31`; строка `1120` присутствует; статус `needs_check`
|
||
|
||
#### Scenario: Суммы в скобках и с пробелами (`aep_3.json`, ОФР)
|
||
- **WHEN** ячейка значения содержит `(7744536}` с `confidence 88.9`, другая — `(6032409)` с `confidence 100`, третья — `13 070` с `confidence 100`
|
||
- **THEN** `amount` соответственно `7744536` (закрывающая скобка не распознана, минус не выставлен, `is_recognized = false`), `-6032409` (`is_recognized = true`), `13070` (`is_recognized = true`); `string_value = null` у всех трёх (суммы разобраны)
|
||
|
||
#### Scenario: Нечисловая ячейка значения
|
||
- **WHEN** ячейка значения содержит `не ****` с `confidence 5.6`
|
||
- **THEN** `amount = null`, `string_value = "не ****"`, `is_recognized = false`
|
||
|
||
#### Scenario: Повтор кода строки
|
||
- **WHEN** в таблице баланса две строки с кодом `1150`
|
||
- **THEN** в документе одна строка `1150` (первая по порядку), предупреждение `<имя json>: table:<i>: код 1150 повторяется: оставлена первая строка`, статус `needs_check`
|
||
|
||
#### Scenario: Тип по кодам при зашумлённом label
|
||
- **WHEN** `label.form_name` таблицы — `Бvxгалтерский баланс` (не сопоставлен), коды строк `1110…1700`
|
||
- **THEN** `document_type = BALANCE`, предупреждение «тип определён по кодам»
|
||
|
||
#### Scenario: Label противоречит единодушным кодам
|
||
- **WHEN** `label.form_name` таблицы — `Бухгалтерский баланс`, все коды строк данных `2110…2910`
|
||
- **THEN** `document_type = INCOME`, `header.form_name = "Бухгалтерский баланс"`, предупреждение
|
||
`тип по label (Бухгалтерский баланс) не совпал с кодами: взят INCOME`, статус `needs_check`
|
||
|
||
#### Scenario: Label противоречит смешанным кодам
|
||
- **WHEN** `label.form_name` таблицы — `Отчет о финансовых результатах`, коды строк данных `1150`, `1100` и `2110`
|
||
- **THEN** `document_type = INCOME`, предупреждение `тип по кодам (BALANCE) не совпал с label (Отчет о финансовых
|
||
результатах)`, статус `needs_check`
|
||
|
||
#### Scenario: Таблица без столбца кода
|
||
- **WHEN** в таблице нет строки с ячейкой «Код» и ни в одном столбце нет кодов из справочника
|
||
- **THEN** таблица пропущена, предупреждение `<имя json>: table:<i>: нет шапки и столбца кода`; остальные таблицы файла разобраны
|
||
|
||
### Requirement: Справочники нормализации — данные с редакциями форм
|
||
Правила SHALL лежать в `api/rules/` (`SCAN_RULES_DIR`) и загружаться один раз при старте с pydantic-валидацией; битый
|
||
файл SHALL прерывать старт сервиса. `statement_lines.yaml` SHALL содержать список `editions` с полями `id`,
|
||
`valid_from`/`valid_until` (даты отчёта) и `lines` (код → наименование); редакция `66n` (до 2024-12-31) заполнена,
|
||
`fsbu4` (с 2025-01-01) присутствует и может быть пустой. Редакция документа SHALL выбираться по `header.report_date`,
|
||
при его отсутствии — по самой поздней `period_date` колонок, при отсутствии обеих — по числу совпадений кодов с
|
||
предупреждением «редакция определена по кодам». Пустая выбранная редакция → предупреждение «редакция <id> не
|
||
заполнена», имена строк — из OCR. Код, отсутствующий в выбранной непустой редакции, но присутствующий в другой → предупреждение
|
||
«код <код> из редакции <id>» (для пустой редакции не выдаётся — достаточно «редакция <id> не заполнена»; решение №23). `ocr_lookalikes.yaml` SHALL задавать таблицу двойников; `statement_labels.yaml` —
|
||
нормализованные подстроки `label` → `BALANCE`/`INCOME`. Коды справочника SHALL быть строками из 4–5 цифр без дублей.
|
||
|
||
#### Scenario: Редакция по дате отчёта
|
||
- **WHEN** `header.report_date = 2024-12-31`, строка с кодом `1150`
|
||
- **THEN** `line_name = "Основные средства"` из редакции `66n`, предупреждений о редакции нет
|
||
|
||
#### Scenario: Редакция 2025 не заполнена
|
||
- **WHEN** `report_date = null`, самая поздняя `period_date = 2025-06-30`, редакция `fsbu4` пуста
|
||
- **THEN** имена строк взяты из ячеек OCR, предупреждение `редакция fsbu4 не заполнена`, статус `needs_check`
|
||
|
||
#### Scenario: Битый справочник
|
||
- **WHEN** в `statement_lines.yaml` код `11A0`
|
||
- **THEN** сервис не стартует, в логе ошибка валидации с именем файла и кодом
|
||
|
||
### Requirement: Статус результата нормализации
|
||
Если из успешных файлов не собран ни один документ — `status = error`, `error.code = RECOGNITION_FAILED`,
|
||
`error.message = "ни одной таблицы не разобрано"`, причины по таблицам — в `metrics.warnings`. Если документы есть —
|
||
`status = done` только когда у всех документов `inn_confidence`, `organization_name_confidence` равны 100,
|
||
`report_date` и все `period_date` заполнены, все `line_code_confidence` и `line_name_confidence` равны 100, все значения
|
||
`is_recognized`, тип совпал с `label`, редакция определена по дате и заполнена, и нет предупреждений уровня таблицы;
|
||
иначе `status = needs_check`. Если среди файлов набора есть ошибочный (при хотя бы одном успешном), статус не может быть
|
||
`done` — `needs_check` (потерян документ набора; решение №28). Предупреждения уровня таблицы SHALL иметь вид `<имя json>: table:<i>: <текст>`, уровня
|
||
файла — `<имя json>: <текст>`; `metrics.rows` — сумма строк всех документов.
|
||
|
||
#### Scenario: Ни одной таблицы
|
||
- **WHEN** единственный успешный файл содержит `table_structure: []`
|
||
- **THEN** конверт `status = error`, `error.code = RECOGNITION_FAILED`, `raw_s3_key` заполнен
|
||
|
||
#### Scenario: Всё распознано
|
||
- **WHEN** синтетический ответ, где все `confidence = 100`, тексты чистые, шапка есть, даты колонок и отчёта разобраны, `report_date = 2024-12-31`, коды из редакции `66n`
|
||
- **THEN** `status = done`, поле `metrics.warnings` отсутствует
|
||
|
||
#### Scenario: Исключение в одной таблице
|
||
- **WHEN** разбор `table_structure[1]` бросает исключение, `table_structure[0]` разбирается
|
||
- **THEN** один документ, предупреждение `<имя json>: table:1: BACKEND_PARSING_ERROR: <класс>: <сообщение>`, статус `needs_check`
|
||
|
||
### Requirement: Subject'ы и учётные данные NATS из настроек окружения
|
||
Сервис SHALL брать subject'ы обмена из `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT`, `NATS_IMAGE_REQUEST_SUBJECT`,
|
||
`NATS_RESULT_SUBJECT` (умолчания `files.tabular_uploaded`, `files.img_uploaded`, `files.tabular_parsed`): подписан на `image_request` (queue group `scan-gateway`), публикует ack и результат в `result`.
|
||
При старте SHALL проверяться все три имени (непустое, без пробельных символов, без `*` и `>`, без пустых токенов,
|
||
попарно различны); при нарушении приложение MUST NOT стартовать. Значения `NATS_JWT` и `NATS_SEED` MUST NOT попадать в
|
||
логи, `repr` настроек и тексты ошибок валидации. Используемые subject'ы SHALL писаться в лог при старте и отдаваться в
|
||
`/healthcheck` объектом `subjects` с ключами `image_request`, `result` при любом значении `nats` (`ok | down | off`).
|
||
|
||
#### Scenario: Умолчания
|
||
- **WHEN** приложение стартует без ключей `OPENBAO__NATS__NATS_*_SUBJECT`
|
||
- **THEN** `/healthcheck.subjects` содержит имена `files.*` по умолчанию, обмен идёт по ним
|
||
|
||
#### Scenario: Subject'ы окружения
|
||
- **WHEN** заданы `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT=test.files.tabular_uploaded`,
|
||
`OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT=test.files.img_uploaded`,
|
||
`OPENBAO__NATS__NATS_RESULT_SUBJECT=test.files.tabular_parsed`
|
||
- **THEN** подписки и публикации идут только по этим subject'ам, `/healthcheck.subjects` их показывает
|
||
|
||
#### Scenario: Недопустимый subject
|
||
- **WHEN** `OPENBAO__NATS__NATS_RESULT_SUBJECT` совпадает с subject'ом запроса или содержит `>`
|
||
- **THEN** приложение не стартует, ошибка называет ключ
|
||
|
||
#### Scenario: JWT не раскрывается
|
||
- **WHEN** заданы `NATS_JWT` и `NATS_SEED`, а валидация настроек падает на другом поле
|
||
- **THEN** текст ошибки и лог не содержат значений JWT и seed; `repr` настроек показывает их скрытыми
|
||
|
||
#### Scenario: Тесты на своих subject'ах
|
||
- **WHEN** тесты интеграционные (`tests/integration/`) запускаются при работающих контейнерах стенда на том же NATS
|
||
- **THEN** тесты используют уникальные subject'ы из настроек и не получают и не забирают сообщения стенда
|