Andrey Epifancev 94ab9e90d1 openspec: архив hotfolder-direct-write (#26)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:23:21 +04:00

331 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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; 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`, предупреждение; `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: Таблица без столбца кода
- **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`