Andrey Epifancev cb0bd062d4 test(statements): test_inn_not_taken_from_auditor_block_above_organization_inn (Fix round 3)
Добавлен тест, проверяющий, что ИНН аудитора НИКОГДА не берётся, даже когда
аудиторский блок расположен РАНЬШЕ (выше) строк с ИНН организации.

Лист:
- Строка 3-5: блок аудитора (auditor_from=3)
  - Строка 4: аудиторский ИНН = 7716021332
- Строки 6-8: организация
  - Строка 8: org ИНН = 3249004697

С охраной min(last_row, auditor_from-1) = min(9, 2) = 2:
  Поиск в строках 1-2, ИНН не найден → header.inn is None ✓ GREEN

Без охраны (поиск в строках 1-9):
  Аудиторский ИНН найден первым → header.inn = 7716021332 ✗ RED

Этот тест доказывает, что guard критически важен для исключения аудитора.

Refs #14

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b
2026-10-06 16:42:03 +04:00

tabular-parser — разбор XLS/XLSX/XLSB для АРМ КА

Stateless-сервис распознавания табличных файлов (ОСВ по счетам, ББ, ОФР из Excel) для автоматизированного рабочего места кредитного аналитика ДКБ (АО «Россельхозбанк»). Сервис не имеет доступа к БД и фронта и видит только две внешние системы: NATS (core, без JetStream) и S3 (MinIO, один бакет). Он получает задание, читает исходный файл, возвращает структуру файла и ничего не знает про организации, периметры и план счетов — нормализацией занимается arm-core.

Обмен с arm-core идёт по контракту v1.1: схемы — ../docs/contracts/, копия — в api/contracts/ (идентичность с источником проверяется в make check).

Срез 0 — скелет. Транспорт работает полностью (подписка, ack, чтение S3, публикация результата, ошибки транспорта); разбор подключается через интерфейс Parser без изменений воркера.

Срез 2a — парсер ОСВ. По умолчанию (get_parser()) разбор делает OsvParser: оборотно-сальдовые ведомости по счетам из XLS/XLSX/XLSB, структура определяется по содержимому, достоверность подтверждается контрольными суммами (§1 05-domain-rules.md). Листы с формами ББ/ОФР пропускаются с предупреждением (подсрез 2b). Заглушка NullParser (error = NOT_IMPLEMENTED) осталась только для тестов воркера. Устройство парсера — в разделе «Парсер ОСВ» ниже.

Репозиторий — часть workspace АРМ КА. Общий контекст, модель, правила предметной области и контракты — в соседних репозиториях:

  • ../CLAUDE.md — инварианты и процесс работы всего workspace;
  • ../docs/ — контекст (context/), контракты (contracts/), окружение разработки (dev/);
  • CLAUDE.md — соглашения этого репозитория.

Репозитории должны лежать рядом: tabular-parser/ и docs/ — соседние каталоги (так же устроено зеркало .wt/<срез>/). Пути ../docs/... ниже и в Makefile рассчитаны на это.

Как обрабатывается запрос

  1. arm-core публикует запрос в NATS, subject recognition.tabular.request (схема recognition-request), исходный файл лежит в S3 под ключом uploads/{file_id}/{job_id}/<имя>.
  2. Сервис подписан на этот subject в queue group tabular-parser — несколько экземпляров делят нагрузку между собой.
  3. Запрос валидируется по схеме. Если job_id ещё не обработан этим процессом (process-local LRU), сервис до чтения файла публикует в recognition.result конверт status = processing (ack) с заголовком Nats-Msg-Id = ack:<job_id>:<attempt>. Повторный запрос с тем же job_id получает повторный ack и второй раз не обрабатывается.
  4. Файл читается из S3 (s3.bucket / s3.key) и передаётся парсеру («байты + тип файла → документы, метрики или ошибка»).
  5. Результат (схема recognition-result) записывается в S3 под ключом results/{file_id}/{job_id}/result.json.
  6. В recognition.result публикуется конверт результата (схема recognition-result-message; статусы processing | done | needs_check | error) с заголовком Nats-Msg-Id = result:<job_id>:<attempt>. Публикация при неудаче повторяется до PUBLISH_RETRIES раз (после первой попытки, с паузой 2 с).

Ошибки транспорта процесс не роняют, а превращаются в конверт status = error: нечитаемый или несовместимый запрос — SCHEMA_MISMATCH (ack при этом не публикуется), объект S3 не читается — FILE_READ_ERROR, исключение, вышедшее из парсера, — UNKNOWN_ERROR. Ожидаемые сбои разбора (не тот формат, нет листа ОСВ, лимит размера или времени) парсер возвращает сам исходом status = error с кодом контракта — см. «Статусы и коды» ниже.

Состав репозитория

api/                  бэкенд (все команды разработки — отсюда или через make из корня)
  run_server.py       точка входа платформы (uvicorn; SERVER__HOST, SERVER__PORT)
  src/app.py          FastAPI: lifespan (MinIO, NATS, воркер), GET /healthcheck, middleware, обработчики ошибок
  src/core/           config.py (настройки), clients.py (S3), nats.py (соединение NATS), worker.py (воркер
                      распознавания), logger.py, exception_handlers.py
  src/parser/         парсер ОСВ (Срез 2a): интерфейс Parser, OsvParser, читатели, детекторы, иерархия, классификация,
                      контрольные суммы, CLI; заглушка NullParser — для тестов воркера (модули — ниже)
  src/contract/       pydantic-модели контракта v1.1
  contracts/          копии JSON-схем контракта и примеры
  rules/              словари синонимов *.yaml (osv_headers, osv_groups, periods, data_types) и README
  scripts/            golden_update.py — обновление golden-снимков
  tests/              pytest: unit, integration (маркер `integration`), fixtures (маркер `fixtures`), synthetic
setup/                pip.conf и setup.sh (создание venv без uv)
raisa_config.yml      конфигурация платформы РАИСА
Makefile              команды разработки
Dockerfile            образ сервиса, python:3.11-slim
openspec/             требования и дельта текущего среза (openspec/changes/slice-2a-osv-parser/)

Предварительные требования

  • Python 3.11 (версия платформы) и uv — make venv создаёт окружение и ставит зависимости через uv; uv сам подберёт интерпретатор 3.11, системный python3 другой версии не подойдёт.
  • Docker и compose v2 — для NATS и MinIO (compose лежит в ../docs/dev/; он же поднимает PostgreSQL для arm-core, этому сервису БД не нужна).
  • make.

Пакеты raisa_* из Nexus банка вне банка недоступны: make venv пропускает строки raisa_* в requirements.txt.

Команды make

Выполняются из корня репозитория; инструменты берутся из api/.venv.

Команда Что делает
make venv создаёт api/.venv (Python 3.11) и ставит requirements.txt и requirements-dev.txt через uv
make lint ruff check ., ruff format --check ., mypy src
make test pytest -m "not integration" — без Docker; включает тесты маркера fixtures (golden, property, покрытие словарей), без каталога ../fixtures они пропускаются
make test-integration pytest -m integration — против NATS и MinIO из compose
make test-fixtures pytest -m fixtures — тесты на реальных ОСВ и синтетике из ../fixtures: golden-снимки, покрытие словарей, property-тест
make golden-update перезаписывает golden-снимки в ../fixtures/golden/expected_osv_parse/ и печатает unified diff изменившихся
make golden-check то же без записи; код возврата 1, если снимки расходятся с разбором
make check make lint + make test (включая идентичность копии контракта с ../docs/contracts); с каталогом fixtures ≈ 95 с — из-за тестов на 82 файлах
make check-full make check + make test-integration + docker build; запускать перед PR (нужен поднятый compose, make up)
make up docker compose -f ../docs/dev/docker-compose.yml up -d --wait postgres nats minio

Интеграционные тесты (make test-integration, make check-full) поднимают сервис в процессе теста и вступают в queue group tabular-parser на реальной шине: на шине compose не должно работать другого экземпляра tabular-parser (и наоборот: на время теста in-process воркер ответит ошибкой разбора (UNSUPPORTED_FORMAT / UNREADABLE) на реальные запросы других производителей).

Парсер ОСВ

Парсер — набор чистых функций над единой моделью листа; воркер знает только интерфейс Parser.parse(data, file_type) -> ParseOutcome. Структура ОСВ ищется по содержимому: ни букв колонок, ни номеров строк, ни имён листов в коде нет (это проверяет AST-линтер, см. ниже), синонимы лежат в api/rules/*.yaml.

Модули (api/src/parser/)

Конвейер идёт сверху вниз; все модули, кроме grid.py и readers/, оперируют индексами из содержимого и не знают координат.

Модуль Что делает
grid.py модель листа: Cell, Sheet, MergedRange, column_letter (буква колонки — только для диагностики)
readers/ чтение книги в Sheet: read_book(data, file_type) -> (листы, предупреждения); формат определяется по сигнатуре байтов (xlsx — openpyxl, xls — xlrd с formatting_info, xlsb — pyxlsb, без outline level); несовпадение с file_type — предупреждение; до библиотеки чтения — лимиты чтения (_limits.py, см. «Лимиты»)
rules.py загрузчик словарей rules/*.yaml в frozen-модели (pydantic), normalize_text, Pattern; ошибка словаря — RuntimeError с файлом и ключом при старте
detect.py вид листа (classify_sheet: ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы (find_table_header: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных)
header.py шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок group_schema (варианты шапки 1 и 2)
hierarchy.py каскад стратегий уровней строк: outline → indent → account_pattern → sums; trivial — меньше двух строк данных; выбранная стратегия пишется в metrics.warnings как hierarchy=<имя>
numbers.py суммы в Decimal с двумя знаками (to_money), ИНН из текста ячейки (inn_digits); float в домене нет
classify.py сбор строк таблицы (collect_body_rows: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, data_type, extra (classify_rows)
checks.py контрольные суммы §1.4: тождество сальдо строки, «сумма детей = родитель», «Итого» = корень
osv.py оркестратор OsvParser: лимит размера → чтение (с лимитами чтения) → лист за листом → статус и метрики
deadline.py кооперативный таймаут: шаги сами вызывают Deadline.check (поток прервать нельзя)
errors.py ожидаемые сбои с кодом контракта (UnreadableError, UnsupportedFormatError, InvalidFormatError, InvalidDocumentError, ParseTimeoutError)
cli.py разбор одного файла из командной строки (см. «Запуск на файле»)
__init__.py интерфейс Parser, ParseOutcome, NullParser, get_parser(settings)

get_parser(settings) создаёт OsvParser один раз при старте приложения (кэша нет); словари читаются и проверяются там же, поэтому ошибка в YAML останавливает старт, а не падает на первом файле.

Статусы и коды

documents — по одному osvDocument на лист ОСВ (locator = sheet:<имя>). Строка «Итого» входит в rows как group_type = TOTAL (ASSUMPTION(OQ-01): хранить ли её, решает ядро); metrics.rows считает строки данных без неё.

Исход Когда
done контрольные суммы §1.4 сошлись на всех листах
needs_check нарушены контрольные суммы хотя бы на одном листе (checks: total_row_matches_root, balance_identity_violations, parent_sum_violations; причины и номера строк — в metrics.warnings); структуру под сходимость парсер не подгоняет. Строка «Итого» не найдена — тоже needs_check
error / INVALID_DOCUMENT в книге нет ни одного листа ОСВ (в сообщении — число просмотренных листов)
error / INVALID_FORMAT лист ОСВ найден, но нет заголовка таблицы, обязательной группы сумм («обороты», «сальдо на конец» — отсутствовать может только «начало») или не удалось определить иерархию; в сообщении — лист и шаг
error / UNREADABLE книга не открывается (повреждённый zip/BIFF); пишется в лог уровня WARNING
error / UNSUPPORTED_FORMAT сигнатура не XLS/XLSX/XLSB, размер больше PARSE_MAX_BYTES либо превышен лимит чтения (распакованный zip, площадь объединённых ячеек — см. «Лимиты»)
error / TIMEOUT превышен PARSE_TIMEOUT_SECONDS
error / RECOGNITION_FAILED непредвиденное исключение, одной строкой Тип: сообщение; стек — в лог уровня ERROR («Непредвиденная ошибка разбора: …»)

В metrics.warnings — диагностика, не влияющая на статус: sheet=<имя> hierarchy=<стратегия>, пропущенные листы (skipped: statement (slice 2b), skipped: нет признаков ОСВ), несовпадение file_type с содержимым, свёрнутое сальдо, нераспознанные числа с номером строки, непрочитанные поля шапки, «нет строк данных» (пустая ОСВ).

Контрольные суммы (§1.4)

Допуск ±0.01, пустое значение = 0. Строки «Вал.», «Кол.» и код валюты (data_type = CURRENCY | QUANTITY) в суммирование не входят. Нетривиальное правило — сверка «сумма детей = родитель»: обороты сверяются по колонкам, сальдо — по колонкам либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто дебет − кредит: 1С показывает счёт нетто, а субсчета и контрагентов — развёрнуто (решение R-21, design №31). Остаточный риск: перестановка сторон при совпавшем нетто родителя этой проверкой не ловится — её ловят тождество строки и «Итого = корень».

«Итого». Основная итоговая строка — первая TOTAL листа; она сверяется с первым корнем ACCOUNT либо, если корней уровня 0 несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03), с суммой строк уровня 0 (OQ-106, OQ-133; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя выше). Вторая итоговая строка сразу за «Итого» («Итого развернутое», total_row_secondary) — тоже TOTAL, в сверку не входит. Строка сумм без подписи в конце таблицы (1С без «Итого») принимается за итоговую с предупреждением.

ИНН контрагента (OQ-100, OQ-130): counterparty_inn берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из строки без сумм прямо над названием на том же уровне оформления (такая строка в rows не попадает, в metrics.warnings — «ИНН … над строкой … отнесён к ней»), затем из конца текста названия («Полиграф, 3528315710», «ООО Альфа (ИНН 7701234567)»; 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»). Из текста ИНН отрезается всегда: counterparty_name без ИНН и разделителя, row_value — исходный текст. Только для строк COUNTERPARTY; договоры и прочее не трогаются. Правила — osv_headers.yaml: counterparty_inn_suffix, inn_only_row, inn_kpp_cell.

Период-диапазон. «Октябрь 2024 г. – Сентябрь 2025 г.» (periods.yaml: months, range): январь–конец квартала — нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — period_year = год конца, period_quarter и is_cumulative = null и предупреждение «не с начала года» (OQ-131: ядро такой документ к версии не привязывает).

Словари и как добавить синоним

Словари — данные: osv_headers.yaml (признаки листов, группы сумм, «Дебет/Кредит», колонка ИНН, «Итого», метки шапки), osv_groups.yaml (группировки → ACCOUNT/DEPARTMENT/COUNTERPARTY/CONTRACT/OTHER), periods.yaml (период → квартал и нарастающий итог), data_types.yaml (БУ/НУ/сумма, строки «Вал.»/«Кол.», коды и написания валют). Формат и правила записи паттернов — api/rules/README.md.

Новый формат подключается строкой в YAML, без правки src/. Пример: выгрузка с заголовком «Нач. остаток» вместо «Сальдо на начало» не распознаётся штатным словарём (результат needs_check, суммы начала уходят в extra). Добавьте паттерн в osv_headers.yaml:

groups:
  opening:
    - 'нач\.\s*остаток'

и положите файл в ../fixtures/osv/ (или синтетику в tests/synthetic/generate.py), затем make golden-update. Этот сценарий проверяют test_default_dictionary_does_not_know_the_new_synonym (до правки) и test_new_synonym_parses_without_code_changes (после правки временной копии словарей) в tests/unit/parser/test_book_parser.py. Принцип: новый формат = фикстура + синоним, не новый if.

Лимиты

Настройка По умолчанию Что делает
PARSE_MAX_BYTES 50 МиБ файл больше → UNSUPPORTED_FORMAT, книга не открывается
PARSE_TIMEOUT_SECONDS 120 кооперативный таймаут: проверяется между листами, в циклах по всему листу (поиск заголовка таблицы, дополнительные колонки, строки таблицы, стратегии иерархии) → TIMEOUT. Чтение книги библиотекой (openpyxl/xlrd/pyxlsb) он не прерывает

Фазу чтения таймаут не ограничивает, а лимит размера файла не ограничивает распакованный объём, поэтому до вызова библиотеки чтения действуют лимиты чтения (api/src/parser/readers/_limits.py, ruling R-23) → UNSUPPORTED_FORMAT:

Константа Значение Что ограничивает
ZIP_UNCOMPRESSED_LIMIT_FACTOR 10 xlsx/xlsb: сумма объявленных несжатых размеров записей zip ≤ 10 × PARSE_MAX_BYTES (реальные книги распаковываются в 1,4–7,7 раза)
MERGED_AREA_LIMIT 200 000 ячеек суммарная площадь объединённых ячеек книги (реальный максимум 10 090): xlsx — до openpyxl, потоковым разбором expat всех XML-частей архива (см. ниже); любой формат — после чтения, по диапазонам, которые индекс Sheet раскладывает по ячейкам (площадь ≤ grid.MAX_INDEXED_AREA = 10 000; merged на весь столбец не считается — индекс его не раскладывает)
LARGE_MERGED_COUNT_LIMIT 1000 диапазонов любой формат, после чтения: число крупных объединённых диапазонов листа (площадь > grid.MAX_INDEXED_AREA), которые индекс Sheet не раскладывает и перебирает линейно на каждое обращение (1000 — ≈ 31 мкс на обращение, 100 000 — ≈ 3,2 мс); в реальных книгах — единицы

Любой реальный файл разбирается быстрее 5 с (измерено: максимум ≈ 0,6 с).

Скан объединённых ячеек xlsx (ruling R-25) повторяет то, что разложит openpyxl, с запасом в сторону отказа: сканируется каждая некаталожная запись zip, без отбора по имени или началу содержимого (openpyxl идёт к листу по цели связи в workbook.xml.rels, расширение части не проверяет, а начало XML можно отодвинуть пробелами; бинарную часть expat отвергает на первых байтах, ошибка разбора части не *.xml лишь заканчивает её скан, находка I-1б); считается элемент с локальным именем mergeCell в любом пространстве имён (openpyxl берёт дочерние mergeCell по локальному имени) и только атрибут ref без префикса; ссылки на символы (&#50;) раскрывает парсер, > в кавычках тег не обрывает; площадь — по строке ref (CellRange openpyxl), ячейки не перебираются; отказ — сразу при превышении. Битый XML части *.xml и ref, который openpyxl не разберёт, — UNREADABLE. Стоимость скана: самый большой реальный xlsx (454 КБ распакованно) — 12 мс при чтении openpyxl 0,58 с; синтетический лист в 2 млн ячеек (63 МБ распакованно) — 0,8 с при чтении openpyxl 10 с.

Известные ограничения. Лимиты выше покрывают распакованный объём и площадь объединённых ячеек. Не ограничены (остаточный риск, OQ-108 в ../docs/context/07-open-questions.md):

  • бомба по объёму самих ячеек в пределах лимита распакованного zip: xlsx в 5 МБ с 2 млн обычных ячеек читается openpyxl 13 с и занимает 1,5 ГБ (измерено ревьюером);

  • объём sharedStrings и стилей в тех же пределах;

  • XML-бомбы entity expansion: defusedxml не подключён; от экспоненты «billion laughs» защищает только сам expat ≥ 2.4 (и в скане, и в openpyxl, который разбирает части тем же expat).

  • предскан — эшелон защиты, а не граница безопасности (R-26): он повторяет разбор openpyxl (тот же expat), но если на части не *.xml expat скана остановится раньше, чем разбор openpyxl, остаток части до openpyxl дойдёт непросканированным; известных таких расхождений нет;

  • память и CPU самого предскана не ограничены размером чанка (сканируется каждая часть, с любым именем): «мусорная» часть xl/junk.xml с одним атрибутом в 400 МБ даёт 1,3 ГБ за 1,2 с, 400 МБ тегов <a/> — 8,5 с CPU (в пределах лимита распакованного zip, 500 МиБ);

  • крупные merged-диапазоны в пределах потолка (LARGE_MERGED_COUNT_LIMIT = 1000 на лист): индекс Sheet по-прежнему просматривает их линейно (≈ 31 мкс на обращение); интервальный индекс — не сделан.

Жёсткий лимит памяти контейнера, defusedxml или разбор в подпроцессе с RLIMIT_AS — решения платформы (они же — настоящая граница безопасности чтения). Книга в пределах лимитов читается библиотекой целиком.

Тесты на реальных данных и golden-снимки

Тесты с маркером fixtures читают файлы из соседнего репозитория fixtures (ARM_KA_FIXTURES=<путь>, по умолчанию ../fixtures рядом с репозиторием); без каталога они пропускаются с причиной. Для каждого файла fixtures/osv/** (включая osv/synthetic/) в fixtures/golden/expected_osv_parse/<тот же путь без расширения>.json лежит golden-снимок результата разбора (как его печатает CLI, без job_id, file_id, service.version и metrics.duration_ms). tests/fixtures/test_golden.py сравнивает снимок с текущим разбором; расхождение — падение теста с unified diff.

Снимок — регрессия, а не оракул: он фиксирует, что парсер выдаёт сейчас, и ничего не доказывает про корректность (её проверяют ручная сверка «Итого» и property-тест). Поэтому после намеренной правки парсера:

make golden-update                    # перезаписать снимки, diff изменившихся — в stdout
git -C ../fixtures diff               # просмотреть каждое изменение: оно должно быть следствием правки
git -C ../fixtures add golden && git -C ../fixtures commit   # снимки — отдельным коммитом в репозитории fixtures

Править снимки вручную нельзя; новый файл в osv/ без снимка — падение «нет снимка: выполните make golden-update». Первый make golden-update для нового файла печатает в stdout файл снимка целиком (как «добавленные» строки diff), для изменившегося — только diff; сводка «файлов / без изменений / новых / изменено / лишних» идёт в stderr. Снимки, которым не соответствует ни один файл, считаются лишними: make golden-update их удаляет, make golden-check завершается кодом 1. Каталог fixtures/osv/synthetic/ — 19 синтетических файлов s01–s19 (генератор — tests/synthetic/generate.py): форматы, которых нет в реальных ОСВ (шапка варианта 2, «Вал.»/«Кол.», xlsb, без outline level, текстовые числа и т. д.). Дополнительно: tests/fixtures/test_rules_coverage.py требует, чтобы каждая запись словарей api/rules/*.yaml сработала хотя бы на одном файле (мёртвый синоним — удалить или добавить синтетику, см. api/rules/README.md), а tests/unit/parser/test_no_coordinates.py — AST-линтер «без координат» в src/parser/**.

Запуск локально против compose

make venv
cp ../docs/dev/.env.example ../docs/dev/.env   # один раз: учётные данные и порты compose (в git не попадает)
make up                                        # NATS (4222), MinIO (9000, консоль 9001), бакет arm-ka
cp api/.env.example api/.env                   # один раз: настройки сервиса (в git не попадает)
cd api && .venv/bin/python run_server.py

Значения из api/.env.example уже совпадают с ../docs/dev/.env (стандартные порты и учётные данные compose); править api/.env нужно, только если вы меняли порты или пароли compose. Подробности про compose, смену портов и сброс — ../docs/dev/README.md.

По умолчанию сервис слушает 0.0.0.0:8000 (SERVER__HOST, SERVER__PORT); api/.env.example сужает адрес до 127.0.0.1:8000 (только loopback). Проверка:

curl -s localhost:8000/healthcheck

В ответе — uptime, version и nats: ok — подключён, down — включён (NATS_ENABLED=true), но связи нет, off — выключен (NATS_ENABLED=false, воркер не запущен).

Если NATS или MinIO недоступны при старте, приложение всё равно поднимается: ошибка логируется, соединение с NATS восстанавливается автоматически (бесконечное автопереподключение).

Запуск на файле

Разбор одного файла без NATS и S3 — из каталога api/:

cd api
ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XLS|XLSX|XLSB]

Тип файла по умолчанию определяется по расширению (.xls, .xlsx, .xlsb, регистр не важен); --file-type его переопределяет, а для неизвестного расширения обязателен. ENV_FILE=.env.test нужен, потому что импорт пакета src читает настройки сервиса (OPENBAO__…); для разбора они не используются, подойдёт и api/.env (см. выше). Симптом без окружения — ValidationError: OPENBAO … Field required при запуске: задайте ENV_FILE=.env.test.

В stdout печатается JSON файла результата контракта v1.1 — тот же, что воркер кладёт в S3 под results/{file_id}/{job_id}/result.json (job_id и file_id — нулевые заглушки). Формат вывода: UTF-8 без экранирования кириллицы, отступ 2 пробела, порядок полей — как в модели контракта, суммы — строки. duration_ms — фактическая, поэтому меняется от запуска к запуску. При status = error печатается {"status": "error", "error": {...}}, а код и сообщение ошибки дублируются в stderr.

Код возврата Значение
0 done
1 needs_check — нарушены контрольные суммы (§1.4)
2 error — разбор вернул ошибку (error.code, error.message)
3 файл не найден / не читается / ошибка использования (нет аргумента, неизвестное расширение без --file-type, недопустимое значение --file-type); у argparse код 2 по умолчанию, здесь он занят статусом error

Пример (начало вывода на ОСВ_66 за 2024.xls из fixtures):

{
  "schema_version": "1.1",
  "job_id": "00000000-0000-0000-0000-000000000000",
  "file_id": "00000000-0000-0000-0000-000000000000",
  "service": {
    "name": "tabular-parser",
    "version": "1.0.0"
  },
  "status": "done",
  "metrics": {
    "duration_ms": 2,
    "rows": 9,
    "warnings": [
      "sheet=Лист_1 hierarchy=outline"
    ]
  },
  "documents": [
    {
      "document_type": "OSV",
      "locator": "sheet:Лист_1",
      "header": {
        "organization_name": "АО \"Русский бисквит\"",
        "organization_inn": null,
        "account_code_raw": "66",
        "period_text": "2024 г.",
        "period_year": 2024,
        "period_quarter": "Q4",
        "is_cumulative": true,
        "data_type": "BU",
        "currency": "RUB",
        "header_variant": 1
      },

Переменные окружения

Настройки читает pydantic-settings (api/src/core/config.py). Вложенные поля задаются через двойное подчёркивание. Файл окружения выбирается переменной ENV_FILE (по умолчанию .env в каталоге api/, тесты используют .env.test).

Переменная По умолчанию Назначение
OPENBAO__MINIO__MINIO_URL — (обязательна) адрес MinIO без схемы, host:port
OPENBAO__MINIO__MINIO_ADMIN_LOGIN — (обязательна) логин S3
OPENBAO__MINIO__MINIO_ADMIN_PASSWORD — (обязательна) пароль S3
OPENBAO__MINIO__MINIO_BUCKET — (обязательна) бакет (arm-ka)
OPENBAO__MINIO__MINIO_SECURE true true — https (платформа), false — http (MinIO из compose)
OPENBAO__NATS__NATS_URL — (обязательна) адрес NATS, nats://host:4222
OPENBAO__NATS__NATS_SEED пусто nkeys seed; пусто вместе с JWT — подключение без аутентификации (dev)
OPENBAO__NATS__NATS_JWT пусто JWT пользователя NATS
DEBUG false уровень логов DEBUG
ENV_FILE .env путь к файлу окружения (от api/)
NATS_ENABLED true false — не подключаться к NATS, воркер не стартует
WORKER_CONCURRENCY 1 сколько заданий обрабатывается одновременно
PUBLISH_RETRIES 3 повторы публикации ack и результата в NATS после первой попытки (пауза 2 с; 0 — без повторов)
DONE_CACHE_SIZE 1000 размер LRU обработанных job_id (дедупликация внутри процесса)
PARSE_MAX_BYTES 52428800 (50 МиБ) лимит размера файла; больше — error UNSUPPORTED_FORMAT
PARSE_TIMEOUT_SECONDS 120 кооперативный таймаут разбора; превышение — error TIMEOUT
SERVER__HOST 0.0.0.0 адрес привязки HTTP-сервера
SERVER__PORT 8000 порт HTTP-сервера

SERVICE_NAME — имя сервиса в конвертах контракта (service.name); допустимо только tabular-parser, иное значение — ошибка конфигурации при старте. Queue group подписки NATS — константа tabular-parser в src/core/nats.py, настройкой не меняется.

Description
No description provided
Readme 5 MiB
Languages
Python 99.7%
Makefile 0.2%