## MODIFIED Requirements ### Requirement: Результат по контракту Сервис SHALL публиковать в `files.tabular_parsed` конверт по `recognition-result-message.schema.json` с `service.name = "scan-gateway"`, `Nats-Msg-Id = result::`; при `status ∈ {done, needs_check}` — файл результата (`recognition-result.schema.json`, `schema_version = "2.0"` — `CONTRACT_VERSION` сервиса; запрос `recognition-request` остаётся `1.x`, запрос `2.0` → `SCHEMA_MISMATCH`) в `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`». Конверт (включая ack `processing`) и файл результата несут `schema_version = "2.0"`, `result_schema_version = "2.0"`; `service.statements_version` (содержимое `api/arm_statements/VERSION`) SHALL быть в файле результата, в конверте `service` — `{name, version}`. Если ошибочны **все** файлы — `status = error`, `error.code` и `error.message` берутся у первого ошибочного файла по имени (для `parse_error` и не-объекта — `INVALID_FORMAT`); если код ИС этого файла не входит в перечень кодов контракта, `error.code = UNKNOWN_ERROR`, а исходный код SHALL быть указан в `error.message`. Если есть хотя бы один успешный файл — документы собираются из успешных файлов по требованию «Нормализация ответа ИС в `statementDocument`», а статус определяется по требованию «Статус результата нормализации». `metrics.warnings` SHALL быть списком структур предупреждений (`code`, `level`, `message`, `locator`, …) по схеме v2.0 — одним и тем же в конверте и в файле результата (предел 100 структур, `message` до 1000 символов; при переполнении остаются все `error`, затем `info`). Каждый ошибочный json набора SHALL давать предупреждение `JSON_FAILED` (`error`, `locator = file:<имя json>`, `message = "<код ИС>: <сообщение ИС>"`; для `parse_error` и не-объекта — код `INVALID_FORMAT`), в том числе когда ошибочны все json; исключение при разборе таблицы — `TABLE_PARSE_ERROR` (`error`, `locator = file:<имя json>;table:`, `message = "BACKEND_PARSING_ERROR: <тип>: <текст>"`). Успешный json без `data_ocr`, без `table_structure` или с пустым `table_structure` SHALL давать `JSON_FAILED`. Если предупреждений нет, поле `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/.json`, `result_s3` указывает на файл результата с двумя документами (`BALANCE`, `INCOME`) #### Scenario: Ошибка ИС - **WHEN** единственный файл ответа `.json` содержит `error.code = INVALID_DOCUMENT` - **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT`, `error.message` из ответа, `raw_s3_key` заполнен, `metrics.warnings` содержит одну структуру `JSON_FAILED` (`error`, `locator = file:.json`, `message = INVALID_DOCUMENT: <сообщение>`) #### Scenario: Ошибка одного из документов - **WHEN** ответ состоит из `.json` (успех, `aep_3.json`) и `_ofr.json` (`INVALID_DOCUMENT`, `aep_3_ofr.json`) - **THEN** конверт `status = needs_check`, документы собраны из `.json`, `metrics.warnings` содержит `JSON_FAILED` (`error`, `locator = file:_ofr.json`, `message = INVALID_DOCUMENT: <сообщение>`) и предупреждения пакета; ядро не автоподтверждает документы файла #### Scenario: Все документы ошибочны - **WHEN** оба файла ответа ошибочны (первый по имени — `INVALID_DOCUMENT`, второй — не JSON) - **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT` (код первого ошибочного файла по имени), `metrics.warnings` содержит два `JSON_FAILED` (по одному на json); у файла не JSON сообщение начинается с `INVALID_FORMAT` #### Scenario: Все файлы не JSON - **WHEN** единственный файл ответа не разбирается как JSON - **THEN** конверт `status = error`, `error.code = INVALID_FORMAT`, `metrics.warnings` содержит один `JSON_FAILED` с `message = 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`, `JSON_FAILED` в `metrics.warnings` с `message = SOME_NEW_CODE: <сообщение>` #### Scenario: Все json набора ошибочны - **WHEN** оба json набора ошибочны (первый по имени — `INVALID_DOCUMENT`) - **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT` (код и сообщение первого ошибочного json), `metrics.warnings` содержит `JSON_FAILED` каждого json #### Scenario: Успешный json без таблиц - **WHEN** успешный json не содержит `data_ocr.table_structure` или `table_structure: []` - **THEN** этот json даёт `JSON_FAILED`; если успешных json с таблицами нет — конверт `status = error` ### Requirement: Нормализация ответа ИС в `statementDocument` Шлюз SHALL оставить у себя обмен с ИС Сканирование (горячие папки, опрос, срок, склейку набора json в `raw/{job_id}.json` до нормализации, очистку папки) и адаптер «таблица `table_structure[i]` json → `FormCandidate`»; шаги 2–8 SHALL выполняться копией пакета `arm_statements` (`api/arm_statements/`, правке не подлежит). Адаптер SHALL: создавать кандидата на каждую таблицу каждого успешного json; копировать шапку json (`organization`, `inn`, `units`, `date`) в шапку каждой своей таблицы как `Cell` (`text = form_name`, `confidence` ИС); передавать `label.form_name` таблицы как заголовок в `evidence` (тип — общим правилом шага 1 по словарю заголовков пакета, `type_hint(okud_text, title_text, rules)`, у OCR `okud_text = None`); строить плотную сетку по `row_index` и `column_index` ИС (пустые позиции — `null`), `Cell.ref = row:`; `locator = file:<имя json>;table:`. `value` у OCR-ячеек всегда `null`, `source = OCR`. Нарушенная структура таблицы ИС SHALL давать исключение адаптера и `TABLE_PARSE_ERROR` всей таблицы, остальные таблицы разбираются. Статус конверта SHALL определяться только `envelope_status` пакета; перевод документов и предупреждений в контракт — только `to_contract` / `warning_to_contract` пакета. #### Scenario: Один json — ББ и ОФР - **WHEN** json `aep_3.json` содержит две таблицы — «Бухгалтерский баланс» и «Отчет о финансовых результатах» — под общей шапкой - **THEN** два кандидата с `locator` `file:aep_3.json;table:0` и `…;table:1`, у каждого копия шапки json #### Scenario: Таблица упала при разборе - **WHEN** разбор `table:1` json `aep_3.json` бросил исключение, `table:0` разобрана - **THEN** документ `BALANCE` из `table:0`; в `metrics.warnings` — `TABLE_PARSE_ERROR` уровня `error` с `locator = "file:aep_3.json;table:1"`; конверт `needs_check` #### Scenario: Таблица ИС только с текстом - **WHEN** в json есть таблица без столбца кодов и без распознанного типа, в ячейках только текст подписей - **THEN** документа нет, в `metrics.warnings` — `TABLE_SKIPPED` уровня `info` с `locator` таблицы; конверт из-за неё не `needs_check` #### Scenario: Чистый скан с шапкой (`aep_6.json`) - **WHEN** нормализуется `aep_6.json` (две таблицы: «Бухгалтерский баланс» и «Отчет о финансовых результатах») - **THEN** два кандидата, из них два документа `BALANCE` и `INCOME` по шагам 2–8 пакета; поля, колонки и значения — по golden `fixtures/ocr/expected_normalize/`; `header.units = "тыс. руб."`; статус определяет `envelope_status` пакета #### Scenario: Зашумлённый скан без шапки (`aep_3.json`) - **WHEN** нормализуется `aep_3.json` (таблица «Бухгалтерский баланс» без строки-шапки, коды с мусором) - **THEN** разбор столбца кода, синтез шапки, предупреждения и статус документа — правилами пакета (шаги 2–8), шлюз их не дублирует; `header.inn` и `report_date` — по правилам очистки пакета; строки без кода сохраняются со статусом строки #### Scenario: Суммы в скобках и с пробелами (`aep_3.json`, ОФР) - **WHEN** ячейка значения содержит `(7744536}` с `confidence 88.9`, другая — `(6032409)`, третья — `13 070` - **THEN** разбор сумм и `is_recognized` — общие правила пакета (шаг 4); шлюз передаёт текст ячейки как есть (`value = None`), без собственного разбора #### Scenario: Нечисловая ячейка значения - **WHEN** ячейка значения содержит `не ****` с `confidence 5.6` - **THEN** `amount = null`, `string_value = "не ****"`, `is_recognized = false` (правило шага 4 пакета) #### Scenario: Повтор кода строки - **WHEN** в таблице баланса две строки с кодом `1150` - **THEN** поведение определяет шаг пакета для повтора кода; шлюз ничего не отбрасывает до шагов 2–8, предупреждение приходит от пакета структурой `metrics.warnings` #### Scenario: Тип по кодам при зашумлённом label - **WHEN** `label.form_name` таблицы — `Бvxгалтерский баланс` (не сопоставлен словарём заголовков), коды строк `1110…1700` - **THEN** тип определяется общим правилом шага 1 пакета (по кодам), предупреждение — структурой пакета #### Scenario: Label противоречит единодушным кодам - **WHEN** `label.form_name` таблицы — `Бухгалтерский баланс`, все коды строк данных `2110…2910` - **THEN** тип и предупреждение о расхождении — по правилам пакета; `header.form_name` — текст `label` как распознан; статус `needs_check` #### Scenario: Label противоречит смешанным кодам - **WHEN** `label.form_name` таблицы — `Отчет о финансовых результатах`, коды строк данных `1150`, `1100` и `2110` - **THEN** тип и предупреждение о расхождении — по правилам пакета; статус `needs_check` #### Scenario: Таблица без столбца кода - **WHEN** в таблице нет строки с ячейкой «Код» и ни в одном столбце нет кодов - **THEN** документа нет, в `metrics.warnings` — `TABLE_SKIPPED` с `locator` таблицы; остальные таблицы файла разобраны ### Requirement: Статус результата нормализации Статус конверта SHALL определяться функцией `envelope_status` пакета `arm_statements` по документам и предупреждениям. Если из успешных файлов не собран ни один документ — `status = error`, `error.code = RECOGNITION_FAILED`, `error.message = "ни одной таблицы не разобрано"`, причины по таблицам — в `metrics.warnings` (`TABLE_PARSE_ERROR`, `TABLE_SKIPPED`, `TABLE_LOST`); если все json ошибочны — код и сообщение первого ошибочного json. Если документы есть — `done` или `needs_check` по `envelope_status`; потерянный json набора (`JSON_FAILED`) или таблица (`TABLE_PARSE_ERROR`, `TABLE_LOST`) исключают `done`. `metrics.rows` — число строк `lines` всех документов, включая строки без кода. #### Scenario: Ни одной таблицы - **WHEN** единственный успешный файл содержит `table_structure: []` - **THEN** json даёт `JSON_FAILED`, конверт `status = error`, `raw_s3_key` заполнен #### Scenario: Все таблицы упали при разборе - **WHEN** в успешном json разбор каждой таблицы бросил исключение - **THEN** конверт `status = error`, `error.code = RECOGNITION_FAILED`, `error.message = "ни одной таблицы не разобрано"`, `metrics.warnings` содержит `TABLE_PARSE_ERROR` каждой таблицы #### Scenario: Потерянный документ набора - **WHEN** `.json` успешен, `_ofr.json` ошибочен - **THEN** статус не `done`, а `needs_check` #### Scenario: Всё распознано - **WHEN** синтетический ответ, где все ячейки распознаны уверенно, шапка и даты разобраны, предупреждений нет - **THEN** `status = done`, поле `metrics.warnings` отсутствует #### Scenario: Исключение в одной таблице - **WHEN** разбор `table_structure[1]` бросает исключение, `table_structure[0]` разбирается - **THEN** один документ, `TABLE_PARSE_ERROR` (`error`, `locator = file:<имя json>;table:1`, `message = BACKEND_PARSING_ERROR: <класс>: <сообщение>`), статус `needs_check` ## REMOVED Requirements ### Requirement: Справочники нормализации — данные с редакциями форм **Reason**: справочник строк (`statement_lines.yaml`) и выбор редакции удалены; двойники (`ocr_lookalikes.yaml`) и заголовки форм (`statement_labels.yaml`) — словари пакета `arm_statements` (`default_rules()`). **Migration**: `api/rules/` и настройка `SCAN_RULES_DIR` удалены; словари меняются через репозиторий `arm-statements` и `sync-statements`.