198 lines
20 KiB
Markdown
198 lines
20 KiB
Markdown
# 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>:<attempt>`. Повторный запрос с `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` v2.0, `schema_version = "2.0"`) в `results/{file_id}/{job_id}/result.json` и
|
||
опубликовать конверт результата (по `recognition-result-message.schema.json` v2.0, `result_schema_version = "2.0"`) с
|
||
заголовком `Nats-Msg-Id = result:<job_id>:<attempt>`. Ack `processing` SHALL нести `schema_version = "2.0"`; запрос
|
||
`recognition-request` остаётся v1.0. `service.statements_version` файла результата SHALL быть коммитом копии пакета
|
||
`arm_statements` (файл `VERSION` копии; сервис без него не стартует) и записываться при наличии `statementDocument`.
|
||
`metrics.warnings` конверта SHALL содержать предупреждения уровня файла структурами `{code, severity, message, locator?}`,
|
||
не строками. Статус конверта — по общему правилу: `done` ⇔ все документы `done` ∧ нет предупреждений файла уровня
|
||
`error`. При неудаче публикации сервис 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: Результат и ack версии 2.0
|
||
- **WHEN** обработан запрос `schema_version = "1.0"` с книгой, содержащей баланс
|
||
- **THEN** ack `processing` и конверт результата несут `schema_version = "2.0"`, `result_schema_version = "2.0"`, файл
|
||
результата проходит схему v2.0, `service.statements_version` равен коммиту копии `arm_statements`
|
||
|
||
#### Scenario: Предупреждение файла структурой
|
||
- **WHEN** в книге лист «Примечания» без признаков ОСВ и формы
|
||
- **THEN** `metrics.warnings` конверта содержит `{code: "SHEET_OTHER", severity: "info", message, locator: "sheet:Примечания"}`
|
||
|
||
#### 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>:<attempt>`,
|
||
и этот `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` (адаптер Excel и копия
|
||
пакета `arm_statements`); прочие листы дают предупреждение файла `SHEET_OTHER`. Файл результата SHALL писаться с
|
||
`schema_version = "2.0"`; документы книги — ОСВ в порядке листов, затем формы в порядке листов. Заглушка `NullParser`
|
||
(`error NOT_IMPLEMENTED`) SHALL использоваться только в тестах воркера и MUST NOT быть реализацией по умолчанию.
|
||
Реализация по умолчанию SHALL создаваться один раз при старте приложения (словари ОСВ читаются и проверяются там же,
|
||
словари форм — `default_rules()` пакета). Каталог `api/rules/` SHALL содержать словари ОСВ (`osv_headers`, `osv_groups`,
|
||
`periods`, `data_types`, `osv_checks`) и README; словарей форм ББ/ОФР в сервисе нет — они часть копии `arm_statements`.
|
||
|
||
#### 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 = "2.0"`, файл результата проходит схему v2.0 и
|
||
содержит один `osvDocument` со `status = done` и `account_code_raw = "66"`
|
||
|
||
#### Scenario: Реальные формы через воркер
|
||
- **WHEN** в интеграционном тесте в S3 лежит книга `statements-xlsx/npz/2025-Q3/…xlsx` и опубликован запрос `file_type = XLSX`
|
||
- **THEN** после ack публикуется конверт `done` с `result_schema_version = "2.0"`, файл результата проходит схему v2.0 и
|
||
содержит два `statementDocument` (`BALANCE`, `INCOME`) со строками `line_status`, `ref`, `checks` и пустыми
|
||
`checks.relations_failed`; расхождения итогов в пределах округления (`1600 = 1700` на 1.94 / 9.02 / 2.48, `2100`/`2200`/
|
||
`2300`) — в `soft_relations_failed` (OQ-138); `service.statements_version` равен коммиту копии пакета
|
||
|
||
#### Scenario: Книга без ОСВ и форм
|
||
- **WHEN** в S3 лежит книга Excel, ни один лист которой не имеет признаков ОСВ или формы
|
||
- **THEN** публикуется конверт `error`, `error.code = INVALID_DOCUMENT`, сообщение называет число просмотренных листов
|
||
|
||
#### Scenario: Сервис без версии общего кода не стартует
|
||
- **WHEN** в копии `arm_statements` нет файла `VERSION`
|
||
- **THEN** `require_statements_version()` бросает `RuntimeError` с текстом «копия arm_statements без VERSION», сервис не
|
||
стартует
|
||
|
||
### Requirement: Копия контракта идентична источнику
|
||
Каждый файл `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` (или `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
|
||
требовать stream'ов или consumer'ов JetStream; при разрыве соединения SHALL переподключаться автоматически и восстанавливать
|
||
подписку; `/healthcheck` SHALL отражать состояние (`nats = ok | down`; `off` — NATS выключен флагом
|
||
`NATS_ENABLED=false`).
|
||
|
||
#### Scenario: NATS перезапущен
|
||
- **WHEN** контейнер NATS перезапущен во время работы сервиса
|
||
- **THEN** в течение 20 с после старта NATS подписка восстановлена, `nats = ok`, новый запрос обрабатывается
|
||
|
||
### Requirement: Subject'ы и учётные данные NATS из настроек окружения
|
||
Сервис SHALL брать subject'ы обмена из `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT`, `NATS_IMAGE_REQUEST_SUBJECT`,
|
||
`NATS_RESULT_SUBJECT` (умолчания `files.tabular_uploaded`, `files.img_uploaded`, `files.tabular_parsed`): подписан на `tabular_request` (queue group `tabular-parser`), публикует ack и результат в `result`.
|
||
При старте SHALL проверяться все три имени (непустое, без пробельных символов, без `*` и `>`, без пустых токенов,
|
||
попарно различны); при нарушении приложение MUST NOT стартовать. Значения `NATS_JWT` и `NATS_SEED` MUST NOT попадать в
|
||
логи, `repr` настроек и тексты ошибок валидации. Используемые subject'ы SHALL писаться в лог при старте и отдаваться в
|
||
`/healthcheck` объектом `subjects` с ключами `tabular_request`, `result` при любом значении `nats` (`ok | down | off`).
|
||
|
||
#### Scenario: Умолчания
|
||
- **WHEN** приложение стартует без ключей `OPENBAO__NATS__NATS_*_SUBJECT`
|
||
- **THEN** `/healthcheck.subjects` содержит имена `files.*` по умолчанию, обмен идёт по ним
|
||
|
||
#### Scenario: Subject'ы окружения
|
||
- **WHEN** заданы `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT=test.files.tabular_uploaded`,
|
||
`OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT=test.files.img_uploaded`,
|
||
`OPENBAO__NATS__NATS_RESULT_SUBJECT=test.files.tabular_parsed`
|
||
- **THEN** подписки и публикации идут только по этим subject'ам, `/healthcheck.subjects` их показывает
|
||
|
||
#### Scenario: Недопустимый subject
|
||
- **WHEN** `OPENBAO__NATS__NATS_RESULT_SUBJECT` совпадает с subject'ом запроса или содержит `>`
|
||
- **THEN** приложение не стартует, ошибка называет ключ
|
||
|
||
#### Scenario: JWT не раскрывается
|
||
- **WHEN** заданы `NATS_JWT` и `NATS_SEED`, а валидация настроек падает на другом поле
|
||
- **THEN** текст ошибки и лог не содержат значений JWT и seed; `repr` настроек показывает их скрытыми
|
||
|
||
#### Scenario: Тесты на своих subject'ах
|
||
- **WHEN** тесты roundtrip (`tests/integration/test_roundtrip.py`) запускаются при работающих контейнерах стенда на том же NATS
|
||
- **THEN** тесты используют уникальные subject'ы из настроек и не получают и не забирают сообщения стенда
|