# tabular-recognition-service Specification ## Purpose Stateless-сервис разбора табличных файлов (XLS/XLSX/XLSB): принимает задания из NATS без JetStream, подтверждает их, читает исходник из S3, отдаёт результат в S3 и конверт в NATS по контракту v1.1, не имея доступа к БД и фронта. С Среза 2a реализация по умолчанию разбирает ОСВ (спека `osv-parsing`); заглушка `NullParser` — только для тестов воркера. ## Requirements ### Requirement: Сервис не зависит от БД и фронта Репозиторий MUST NOT содержать каталоги `web/`, `alembic/`, `src/db/`, `src/models/`, `src/repositories/`; зависимости `SQLAlchemy`, `asyncpg`, `alembic`, `smbprotocol`, `raisa_fastapi_protected_api` MUST NOT присутствовать в `requirements.txt`; настройки `OPENBAO.DATABASE` и `OPENBAO.SMB` MUST NOT требоваться для старта. #### Scenario: Старт с минимальным окружением - **WHEN** сервис запускается с `.env`, содержащим только `OPENBAO__MINIO__*`, `OPENBAO__NATS__NATS_URL`, `DEBUG=true` - **THEN** `GET /healthcheck` отвечает 200 с полями `uptime`, `version`, `nats` ### Requirement: Подтверждение запроса до обработки Сервис SHALL быть подписан на `files.tabular_uploaded` в queue group `tabular-parser`. Получив запрос, проходящий `recognition-request.schema.json`, сервис SHALL до чтения файла из S3 опубликовать в `files.tabular_parsed` конверт `status = processing` с `job_id`, `file_id`, `service {name: "tabular-parser", version}` и заголовком `Nats-Msg-Id = ack::`. Повторный запрос с `job_id`, который уже обрабатывается или обработан этим процессом, SHALL получить повторный ack и MUST NOT обрабатываться второй раз. #### Scenario: Ack раньше результата - **WHEN** опубликован запрос из `docs/contracts/examples/request-tabular.json` - **THEN** первым сообщением сервиса в `files.tabular_parsed` является конверт `processing`, и только затем — результат #### Scenario: Повторный запрос во время обработки - **WHEN** тот же запрос доставлен второй раз, пока первый обрабатывается - **THEN** публикуется второй ack `processing`, файл из S3 повторно не читается, результат публикуется один раз ### Requirement: Результат по контракту После подтверждения сервис SHALL прочитать объект `s3.key` из бакета `s3.bucket`, вызвать парсер, записать файл результата (по `recognition-result.schema.json`) в `results/{file_id}/{job_id}/result.json` и опубликовать конверт результата (по `recognition-result-message.schema.json`) с заголовком `Nats-Msg-Id = result::`. При неудаче публикации сервис SHALL повторить её до трёх раз с паузой, затем залогировать ошибку. #### Scenario: Запрос с заглушкой парсера (Срез 0) - **WHEN** воркеру явно передана заглушка `NullParser` (только тесты воркера; реализация по умолчанию — реальный парсер), запрос подтверждён и объект существует в `uploads/` - **THEN** в `files.tabular_parsed` появляется конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `job_id`/`file_id` из запроса, `metrics.duration_ms ≥ 0`; файл результата может отсутствовать #### Scenario: Запрос с реальным парсером - **WHEN** запрос подтверждён и объект в `uploads/` существует, но не является книгой Excel (сигнатура не XLS/XLSX/XLSB) - **THEN** в `files.tabular_parsed` появляется конверт `status = error`, `error.code = UNSUPPORTED_FORMAT` (повреждённая книга — `UNREADABLE`), `job_id`/`file_id` из запроса, `metrics.duration_ms ≥ 0`; файл результата может отсутствовать; корректная ОСВ в том же потоке даёт `done` (см. «Парсер — заменяемый интерфейс») #### Scenario: Повтор публикации - **WHEN** первая публикация конверта результата завершается ошибкой соединения, вторая — успешно - **THEN** конверт опубликован один раз с тем же `Nats-Msg-Id`, обработка не повторяется ### Requirement: Ошибки транспорта превращаются в результат Сервис SHALL отвечать конвертом `status = error` в случаях: тело не проходит схему или `MAJOR` версии ≠ 1 → `SCHEMA_MISMATCH`; объект S3 не читается → `FILE_READ_ERROR`; исключение парсера → `UNKNOWN_ERROR` (сообщение без трассировки). Сервис MUST NOT падать по этим причинам. `SCHEMA_MISMATCH` SHALL публиковаться (без ack), когда из тела читаемы `job_id` и `file_id` (uuid), а `attempt` — из тела или из заголовка запроса `Nats-Msg-Id = :`, и этот `job_id` сервису ещё не известен (не в работе и не среди обработанных). Иначе валидный конверт собрать нельзя либо он ушёл бы под тем же `Nats-Msg-Id`, что и настоящий результат: сервис SHALL записать в лог ошибку (`error_category=schema_mismatch`, причина) — уровнем error, если идентификаторы не читаемы, уровнем warning, если `job_id` уже известен, — и MUST NOT публиковать ни ack, ни результат; ядро обнаружит такое задание по `dlq`/`timeout` (явная запись в контракт — открытый вопрос «невалидный запрос без читаемых `job_id`/`file_id`/`attempt`» в `open-questions-proposal.md`). FILE_READ_ERROR и UNKNOWN_ERROR публикуются всегда: сервис MUST NOT молчать по ним. #### Scenario: Нет объекта в S3 - **WHEN** `s3.key` запроса указывает на несуществующий объект - **THEN** публикуется конверт `error.code = FILE_READ_ERROR`, процесс продолжает работу #### Scenario: Несовместимая версия - **WHEN** запрос содержит `schema_version = "2.0"` - **THEN** публикуется конверт `error.code = SCHEMA_MISMATCH`, ack не публикуется #### Scenario: Невалидный запрос без читаемых идентификаторов - **WHEN** тело запроса не JSON либо в нём не читаются `job_id` или `file_id`, либо `attempt` нет ни в теле, ни в заголовке `Nats-Msg-Id` - **THEN** в лог пишется ошибка `error_category=schema_mismatch`, ни ack, ни результат не публикуются, процесс продолжает работу #### Scenario: Невалидный запрос с уже известным `job_id` - **WHEN** запрос не проходит схему, а его `job_id` уже в работе или среди обработанных - **THEN** в лог пишется предупреждение `error_category=schema_mismatch`, ни ack, ни результат не публикуются; обработка или результат задания с этим `job_id` не меняются ### Requirement: Парсер — заменяемый интерфейс Разбор файла SHALL выполняться через единый интерфейс «байты + тип файла → документы/метрики/ошибка», не знающий о NATS и S3; воркер MUST NOT зависеть от конкретной реализации. Реализация по умолчанию (`get_parser()`) SHALL быть оркестратором книги `BookParser`: он читает книгу один раз, классифицирует листы по содержимому и отдаёт листы ОСВ конвейеру спеки `osv-parsing`, листы форм и их продолжения — конвейеру спеки `statement-parsing`; прочие листы пропускаются с предупреждением. Файл результата SHALL писаться с `schema_version = "1.2"`; документы книги — ОСВ в порядке листов, затем формы в порядке листов. Заглушка `NullParser` (`error NOT_IMPLEMENTED`) SHALL использоваться только в тестах воркера и MUST NOT быть реализацией по умолчанию. Реализация по умолчанию SHALL создаваться один раз при старте приложения (словари читаются и проверяются там же). Каталог `api/rules/` SHALL содержать словари ОСВ (`osv_headers`, `osv_groups`, `periods`, `data_types`), словари форм (`statement_headers`, `statement_periods`, `statement_checks`) и README. #### Scenario: Подмена реализации в тесте - **WHEN** в тесте воркеру передан парсер, возвращающий один `osvDocument` - **THEN** файл результата с этим документом записан в S3, конверт имеет `status = done` и `result_s3` на этот файл #### Scenario: Реальная ОСВ через воркер - **WHEN** в интеграционном тесте в S3 лежит `rb/2024-FY/ОСВ_66 за 2024.xls` и опубликован запрос `file_type = XLS` - **THEN** после ack публикуется конверт `done` с `result_schema_version = "1.2"`, файл результата проходит схему v1.2 и содержит один `osvDocument` с `account_code_raw = "66"` #### Scenario: Реальные формы через воркер - **WHEN** в интеграционном тесте в S3 лежит книга `statements-xlsx/npz/2025-Q3/…xlsx` и опубликован запрос `file_type = XLSX` - **THEN** после ack публикуется конверт `done` с `result_schema_version = "1.2"`, файл результата содержит два `statementDocument` (`BALANCE`, `INCOME`) с пустыми `checks.relations_failed`; расхождения итогов в пределах округления (`1600 = 1700` на 1.94 / 9.02 / 2.48, `2100`/`2200`/`2300`) — в `soft_relations_failed` (OQ-138) #### Scenario: Книга без ОСВ и форм - **WHEN** в S3 лежит книга Excel, ни один лист которой не имеет признаков ОСВ или формы - **THEN** публикуется конверт `error`, `error.code = INVALID_DOCUMENT`, сообщение называет число просмотренных листов ### Requirement: Копия контракта идентична источнику `api/contracts/*.schema.json` SHALL быть байт в байт равны файлам `../docs/contracts/`; pydantic-модели SHALL валидировать все `../docs/contracts/examples/*.json` и порождать JSON, проходящий схему. Проверка входит в `make check`. #### Scenario: `make check` - **WHEN** выполняются тесты контракта при доступном `../docs` (или `ARM_KA_DOCS`) - **THEN** идентичность подтверждена, примеры валидны; при недоступном docs тест идентичности пропущен с предупреждением ### Requirement: Соединение с NATS по правилам шаблона, без JetStream При пустых `NATS_SEED`/`NATS_JWT` сервис SHALL подключаться без учётных данных, иначе — с nkeys/JWT. Сервис MUST NOT требовать stream'ов или consumer'ов JetStream; при разрыве соединения SHALL переподключаться автоматически и восстанавливать подписку; `/healthcheck` SHALL отражать состояние (`nats = ok | down`; `off` — NATS выключен флагом `NATS_ENABLED=false`). #### Scenario: NATS перезапущен - **WHEN** контейнер NATS перезапущен во время работы сервиса - **THEN** в течение 20 с после старта NATS подписка восстановлена, `nats = ok`, новый запрос обрабатывается