@ -25,7 +25,7 @@
|
||||
(`OcrNormalizer`); своего конвертера и своего уплощения шапки нет, шлюз только валидирует словарь моделями
|
||||
`src/contract`.
|
||||
Переносы старого ядра (`ocr/text.py … document.py`) и справочники `api/rules/` удалены: словари меняются через
|
||||
репозиторий `arm-statements`, не здесь. Решения по адаптеру (P5-n, R-n) — `openspec/changes/statements-v2/design.md`.
|
||||
репозиторий `arm-statements`, не здесь. Решения по адаптеру (P5-n, R-n) — `openspec/changes/archive/2026-10-19-statements-v2/design.md`.
|
||||
- Статус документа и конверта определяет пакет (`envelope_status`); шлюз его не пересчитывает. Все json набора
|
||||
ошибочны — `error` с кодом первого ошибочного файла, а `metrics.warnings` всё равно перечисляют `JSON_FAILED` каждого
|
||||
json. Успешный json без таблиц — `JSON_FAILED`. Ни одной таблицы — `error RECOGNITION_FAILED`.
|
||||
@ -55,7 +55,7 @@
|
||||
`<uuid>_<суффикс>.json` (атомарно, через `Tmp`). Промах → набор `SCAN_EMULATOR_DEFAULT`, иначе ответ-ошибка
|
||||
`INVALID_DOCUMENT`. Имя входящего файла на выбор пары не влияет.
|
||||
- Описание сервиса, протокол горячих папок, нормализация, env, запуск и коды ошибок — `README.md`; требования и решения
|
||||
текущей фичи — `openspec/changes/statements-v2/`, Среза 3a — `openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/`, Среза 0 —
|
||||
единой нормализации — `openspec/changes/archive/2026-10-19-statements-v2/`, Среза 3a — `openspec/changes/archive/2026-10-07-slice-3a-ocr-normalizer/`, Среза 0 —
|
||||
`openspec/changes/archive/2026-10-04-slice-0-skeleton/`.
|
||||
|
||||
## Соглашения шаблона
|
||||
|
||||
@ -391,7 +391,7 @@ make golden-update # пересобрать эталоны нормализ
|
||||
## Известные ограничения
|
||||
|
||||
Нормализатор (правила шагов 2–8 — пакет `arm_statements`, ограничения — в `05-domain-rules.md` §11.9 и спеке
|
||||
`statement-normalization`; решения шлюза — `openspec/changes/statements-v2/design.md`):
|
||||
`statement-normalization`; решения шлюза — `openspec/changes/archive/2026-10-19-statements-v2/design.md`):
|
||||
|
||||
- Сетка OCR строится по возрастанию `row_index`, пропуски `row_index` пустыми строками не заполняются (P5-1): шаг 2 не
|
||||
видит «дыр» между строками ИС (в реальных json их нет). Ячейка `value` не строка приводится через `str`, `confidence`
|
||||
|
||||
@ -93,18 +93,15 @@ SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFO
|
||||
### 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`;
|
||||
результата (`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`». Если ошибочны **все** файлы — `status = error`, `error.code` и `error.message` берутся у
|
||||
проверкой «`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`.
|
||||
Если есть хотя бы один успешный файл — документы собираются из успешных файлов по требованию «Нормализация ответа ИС»,
|
||||
а статус определяется по требованию «Статус результата нормализации». В обоих случаях `metrics.warnings` SHALL
|
||||
содержать по одной строке `<имя файла>: <код>: <сообщение>` на каждый ошибочный файл, где `<код>` — исходный код ИС как
|
||||
получен (для `parse_error` и не-объекта — `INVALID_FORMAT`); если предупреждений нет, поле `warnings` SHALL быть опущено.
|
||||
Если есть хотя бы один успешный файл — документы собираются из успешных файлов по требованию «Нормализация ответа ИС в `statementDocument`», а статус определяется по требованию «Статус результата нормализации». `metrics.warnings` SHALL быть списком структур предупреждений (`code`, `severity`, `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:<i>`, `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`)
|
||||
@ -116,19 +113,19 @@ SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFO
|
||||
|
||||
#### 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: <сообщение>`
|
||||
- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT`, `error.message` из ответа, `raw_s3_key` заполнен, `metrics.warnings` содержит одну структуру `JSON_FAILED` (`error`, `locator = file:<job_id>.json`, `message = 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: <сообщение>` и предупреждения нормализации
|
||||
- **THEN** конверт `status = needs_check`, документы собраны из `<job_id>.json`, `metrics.warnings` содержит `JSON_FAILED` (`error`, `locator = file:<job_id>_ofr.json`, `message = INVALID_DOCUMENT: <сообщение>`) и предупреждения пакета; ядро не автоподтверждает документы файла
|
||||
|
||||
#### Scenario: Все документы ошибочны
|
||||
- **WHEN** оба файла ответа ошибочны (первый по имени — `INVALID_DOCUMENT`, второй — не JSON)
|
||||
- **THEN** конверт `status = error`, `error.code = INVALID_DOCUMENT` (код первого ошибочного файла по имени), `metrics.warnings` содержит две строки; файл не JSON в warnings несёт код `INVALID_FORMAT`
|
||||
- **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` содержит одну строку `<job_id>.json: INVALID_FORMAT: <сообщение разбора>`
|
||||
- **THEN** конверт `status = error`, `error.code = INVALID_FORMAT`, `metrics.warnings` содержит один `JSON_FAILED` с `message = INVALID_FORMAT: <сообщение разбора>`
|
||||
|
||||
#### Scenario: Корень ответа не объект
|
||||
- **WHEN** файл ответа — корректный JSON, но корень — массив
|
||||
@ -136,7 +133,15 @@ SHALL хватать `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `SCAN_HOTFO
|
||||
|
||||
#### 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: <сообщение>`
|
||||
- **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: Таймаут ожидания ответа
|
||||
Если готовый набор ответа (см. «Передача файла…») не появился до дедлайна, сервис SHALL опубликовать конверт `status = error`, `error.code = TIMEOUT`;
|
||||
@ -171,12 +176,25 @@ SHALL ждать слот во внутренней очереди, их ack SHA
|
||||
- **THEN** пять ack'ов опубликованы сразу, четыре файла появляются во входящем каталоге, пятый — после завершения любого из четырёх
|
||||
|
||||
### Requirement: Копия контракта идентична источнику
|
||||
`api/contracts/*.schema.json` SHALL быть идентичны `../docs/contracts/`; pydantic-модели SHALL валидировать примеры docs;
|
||||
проверка входит в `make check`.
|
||||
Каждый файл `api/contracts/**/*.json` SHALL быть байт в байт равен своей паре в `../docs/contracts/` (или в `contracts`
|
||||
каталога из `ARM_KA_DOCS`) по правилу спеки docs `recognition-contract`, требование «Копия контракта в сервисе
|
||||
идентична источнику»: схема с `$id` `…/v<M>` — с `<имя>.v<M>.schema.json`, если такой файл есть в docs, иначе с
|
||||
основным; пример — с `examples/v<M>/<имя>.json`, если он есть, иначе с `examples/<имя>.json`; файлы docs другого
|
||||
`MAJOR` не сверяются. pydantic-модели SHALL валидировать все примеры копии `api/contracts/examples/*.json` и порождать
|
||||
JSON, проходящий схему копии. Проверка входит в `make check`.
|
||||
|
||||
#### Scenario: `make check`
|
||||
- **WHEN** выполняются тесты контракта при доступном `../docs`
|
||||
- **THEN** идентичность подтверждена, примеры валидны
|
||||
- **WHEN** выполняются тесты контракта при доступном `../docs` (или `ARM_KA_DOCS`)
|
||||
- **THEN** идентичность подтверждена, примеры валидны; при недоступном docs тест идентичности пропущен с предупреждением
|
||||
|
||||
#### Scenario: Окно перехода на контракт 2.0
|
||||
- **WHEN** в docs основной `recognition-result.schema.json` уже v2.0, рядом `recognition-result.v1.schema.json` и
|
||||
`examples/v1/`, а копия сервиса — v1.2
|
||||
- **THEN** копия сверяется с файлами `.v1` и `examples/v1/` (запрос — с основным файлом), тест проходит
|
||||
|
||||
#### Scenario: Копия устарела
|
||||
- **WHEN** в docs изменён файл, с которым сверяется копия, а копия — нет
|
||||
- **THEN** тест идентичности падает, сообщение называет файл
|
||||
|
||||
### Requirement: Соединение с NATS без JetStream
|
||||
При пустых `NATS_SEED`/`NATS_JWT` сервис SHALL подключаться без учётных данных, иначе — с nkeys/JWT из `OPENBAO__NATS__NATS_SEED`/`NATS_JWT` (значения — секреты); MUST NOT требовать
|
||||
@ -213,134 +231,78 @@ SHALL отражать состояние.
|
||||
- **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` (запятая-разделитель тысяч (`4,200`, `1,234` → 4200, 1234; AURORA-1405, #35; так же точка, после которой ровно три цифры, при любом числе цифр до неё: `4.200`, `837.994`, `1234.567` → 4200, 837994, 1234567, #37) удаляется, запятая → точка, скобки → минус, пробелы и прочие символы
|
||||
отбрасываются, несколько точек — разделители тысяч; прочерк/пусто → `null`; сумма с более чем двумя знаками после запятой (`0,125`) не помещается в `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`).
|
||||
Шлюз 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:<row_index>`; `locator = file:<имя json>;table:<i>`. `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` (две таблицы: «Бухгалтерский баланс» 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`
|
||||
- **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` (таблица «Бухгалтерский баланс» без строки-шапки, коды в `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`
|
||||
- **WHEN** нормализуется `aep_3.json` (таблица «Бухгалтерский баланс» без строки-шапки, коды с мусором)
|
||||
- **THEN** разбор столбца кода, синтез шапки, предупреждения и статус документа — правилами пакета (шаги 2–8), шлюз их не дублирует; `header.inn` и `report_date` — по правилам очистки пакета; строки без кода сохраняются со статусом строки
|
||||
|
||||
#### 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` у всех трёх (суммы разобраны)
|
||||
- **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`
|
||||
- **THEN** `amount = null`, `string_value = "не ****"`, `is_recognized = false` (правило шага 4 пакета)
|
||||
|
||||
#### Scenario: Повтор кода строки
|
||||
- **WHEN** в таблице баланса две строки с кодом `1150`
|
||||
- **THEN** в документе одна строка `1150` (первая по порядку), предупреждение `<имя json>: table:<i>: код 1150 повторяется: оставлена первая строка`, статус `needs_check`
|
||||
- **THEN** поведение определяет шаг пакета для повтора кода; шлюз ничего не отбрасывает до шагов 2–8, предупреждение приходит от пакета структурой `metrics.warnings`
|
||||
|
||||
#### Scenario: Тип по кодам при зашумлённом label
|
||||
- **WHEN** `label.form_name` таблицы — `Бvxгалтерский баланс` (не сопоставлен), коды строк `1110…1700`
|
||||
- **THEN** `document_type = BALANCE`, предупреждение «тип определён по кодам»
|
||||
- **WHEN** `label.form_name` таблицы — `Бvxгалтерский баланс` (не сопоставлен словарём заголовков), коды строк `1110…1700`
|
||||
- **THEN** тип определяется общим правилом шага 1 пакета (по кодам), предупреждение — структурой пакета
|
||||
|
||||
#### Scenario: Label противоречит единодушным кодам
|
||||
- **WHEN** `label.form_name` таблицы — `Бухгалтерский баланс`, все коды строк данных `2110…2910`
|
||||
- **THEN** `document_type = INCOME`, `header.form_name = "Бухгалтерский баланс"`, предупреждение
|
||||
`тип по label (Бухгалтерский баланс) не совпал с кодами: взят INCOME`, статус `needs_check`
|
||||
- **THEN** тип и предупреждение о расхождении — по правилам пакета; `header.form_name` — текст `label` как распознан; статус `needs_check`
|
||||
|
||||
#### Scenario: Label противоречит смешанным кодам
|
||||
- **WHEN** `label.form_name` таблицы — `Отчет о финансовых результатах`, коды строк данных `1150`, `1100` и `2110`
|
||||
- **THEN** `document_type = INCOME`, предупреждение `тип по кодам (BALANCE) не совпал с label (Отчет о финансовых
|
||||
результатах)`, статус `needs_check`
|
||||
- **THEN** тип и предупреждение о расхождении — по правилам пакета; статус `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** сервис не стартует, в логе ошибка валидации с именем файла и кодом
|
||||
- **WHEN** в таблице нет строки с ячейкой «Код» и ни в одном столбце нет кодов
|
||||
- **THEN** документа нет, в `metrics.warnings` — `TABLE_SKIPPED` с `locator` таблицы; остальные таблицы файла разобраны
|
||||
|
||||
### 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` — сумма строк всех документов.
|
||||
Статус конверта 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** конверт `status = error`, `error.code = RECOGNITION_FAILED`, `raw_s3_key` заполнен
|
||||
- **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** `<job_id>.json` успешен, `<job_id>_ofr.json` ошибочен
|
||||
- **THEN** статус не `done`, а `needs_check`
|
||||
|
||||
#### Scenario: Всё распознано
|
||||
- **WHEN** синтетический ответ, где все `confidence = 100`, тексты чистые, шапка есть, даты колонок и отчёта разобраны, `report_date = 2024-12-31`, коды из редакции `66n`
|
||||
- **WHEN** синтетический ответ, где все ячейки распознаны уверенно, шапка и даты разобраны, предупреждений нет
|
||||
- **THEN** `status = done`, поле `metrics.warnings` отсутствует
|
||||
|
||||
#### Scenario: Исключение в одной таблице
|
||||
- **WHEN** разбор `table_structure[1]` бросает исключение, `table_structure[0]` разбирается
|
||||
- **THEN** один документ, предупреждение `<имя json>: table:1: BACKEND_PARSING_ERROR: <класс>: <сообщение>`, статус `needs_check`
|
||||
- **THEN** один документ, `TABLE_PARSE_ERROR` (`error`, `locator = file:<имя json>;table:1`, `message = BACKEND_PARSING_ERROR: <класс>: <сообщение>`), статус `needs_check`
|
||||
|
||||
### Requirement: Subject'ы и учётные данные NATS из настроек окружения
|
||||
Сервис SHALL брать subject'ы обмена из `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT`, `NATS_IMAGE_REQUEST_SUBJECT`,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user