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

39 KiB
Raw Blame History

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 колонок, при отсутствии обеих — по числу совпадений кодов с предупреждением «редакция определена по кодам». Пустая выбранная редакция → предупреждение «редакция не заполнена», имена строк — из OCR. Код, отсутствующий в выбранной непустой редакции, но присутствующий в другой → предупреждение «код <код> из редакции » (для пустой редакции не выдаётся — достаточно «редакция не заполнена»; решение №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