544 lines
62 KiB
Markdown
544 lines
62 KiB
Markdown
# tabular-parser — разбор XLS/XLSX/XLSB для АРМ КА
|
||
|
||
Stateless-сервис распознавания табличных файлов (ОСВ по счетам, ББ, ОФР из Excel) для автоматизированного рабочего
|
||
места кредитного аналитика ДКБ (АО «Россельхозбанк»). Сервис **не имеет доступа к БД и фронта** и видит только две
|
||
внешние системы: NATS (core, без JetStream) и S3 (MinIO, один бакет). Он получает задание, читает исходный файл,
|
||
возвращает структуру файла и ничего не знает про организации, периметры и план счетов — нормализацией занимается
|
||
`arm-core`.
|
||
|
||
Обмен с `arm-core` идёт по контракту **v2.0**: схемы — [`../docs/contracts/`](../docs/contracts/), копия — в
|
||
`api/contracts/` (идентичность с источником проверяется в `make check`).
|
||
|
||
**Срез 0 — скелет.** Транспорт работает полностью (подписка, ack, чтение S3, публикация результата, ошибки транспорта);
|
||
разбор подключается через интерфейс `Parser` без изменений воркера.
|
||
|
||
**Срез 2a — парсер ОСВ.** По умолчанию (`get_parser()`) разбор делает `OsvParser`: оборотно-сальдовые ведомости по
|
||
счетам из XLS/XLSX/XLSB, структура определяется по содержимому, достоверность подтверждается контрольными суммами
|
||
(§1 [`05-domain-rules.md`](../docs/context/05-domain-rules.md)). Листы с формами ББ/ОФР пропускаются с предупреждением
|
||
(подсрез 2b). Заглушка `NullParser` (`error = NOT_IMPLEMENTED`) осталась только для тестов воркера. Устройство парсера —
|
||
в разделе «Парсер ОСВ» ниже.
|
||
|
||
Репозиторий — часть workspace АРМ КА. Общий контекст, модель, правила предметной области и контракты — в соседних
|
||
репозиториях:
|
||
|
||
- [`../CLAUDE.md`](../CLAUDE.md) — инварианты и процесс работы всего workspace;
|
||
- [`../docs/`](../docs/) — контекст (`context/`), контракты (`contracts/`), окружение разработки (`dev/`);
|
||
- [`CLAUDE.md`](CLAUDE.md) — соглашения этого репозитория.
|
||
|
||
Репозитории должны лежать рядом: `tabular-parser/` и `docs/` — соседние каталоги (так же устроено зеркало `.wt/<срез>/`).
|
||
Пути `../docs/...` ниже и в `Makefile` рассчитаны на это.
|
||
|
||
## Как обрабатывается запрос
|
||
|
||
1. `arm-core` публикует запрос в NATS, subject `files.tabular_uploaded` (схема `recognition-request`), исходный
|
||
файл лежит в S3 под ключом `uploads/{file_id}/{job_id}/<имя>`.
|
||
2. Сервис подписан на этот subject в queue group `tabular-parser` — несколько экземпляров делят нагрузку между собой.
|
||
3. Запрос валидируется по схеме. Если `job_id` ещё не обработан этим процессом (process-local LRU), сервис **до чтения
|
||
файла** публикует в `files.tabular_parsed` конверт `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. В `files.tabular_parsed` публикуется конверт результата (схема `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-модели контракта v2.0
|
||
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`](https://docs.astral.sh/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`.
|
||
|
||
**Плоская форма 1С по одному счёту (#59, AURORA-1661):** «по счету: X» в шапке задаёт счёт всем строкам, строка вида
|
||
субконто между названием и периодом не считается организацией, «Итого развернутое» — итоговая строка в любом порядке
|
||
относительно «Итого», плоский список без строк-счетов разбирается стратегией `header_account`, а аналитика расчётных
|
||
счетов (`counterparty_accounts` в `osv_groups.yaml`) без названия вида получает `COUNTERPARTY` с предупреждением.
|
||
Правила — `../docs/context/05-domain-rules.md` §1.1, §1.3, §1.4.
|
||
|
||
### Модули (`api/src/parser/`)
|
||
|
||
Конвейер идёт сверху вниз; все модули, кроме `grid.py` и `readers/`, оперируют индексами из содержимого и не знают
|
||
координат.
|
||
|
||
| Модуль | Что делает |
|
||
|---|---|
|
||
| `grid.py` | модель листа: `Cell`, `Sheet`, `MergedRange`, `column_letter` (буква колонки — только для диагностики) |
|
||
| `readers/` | чтение книги в `Sheet`: `read_book(data, file_type) -> (листы, предупреждения)`; формат определяется по сигнатуре байтов (xlsx — openpyxl; части архива, отличающиеся от ссылок `[Content_Types].xml`/`*.rels` только регистром (`xl/SharedStrings.xml`), до openpyxl переименовываются по ссылкам, как их находит Excel — AURORA-1707; 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` → `header_account` → `sums`; `trivial` — меньше двух строк данных; `header_account` — плоский список под счётом шапки (код счёта в шапке, строк-счетов в теле нет; плоская форма 1С, #59); выбранная стратегия пишется в `warnings[]` документа листа как `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>` (`info`) |
|
||
| `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет |
|
||
| `inn_rows.py` | строки ИНН рядом с контрагентом (восемь раскладок, контрольная сумма без подсказки шапки, противоречие подсказке, OQ-163); выполняется над деревом до классификации |
|
||
| `indicators.py` | строки-показатели (§1.3): метка вида суммы из колонки «Показатели» или текста строки (`read_label`), привязка показателя к строке данных над ним (`levels_with_indicators`) |
|
||
| `classify.py` | сбор строк таблицы (`collect_body_rows`: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, `data_type`, `extra` (`classify_rows`) |
|
||
| `checks.py` | контрольные суммы §1.4: тождество сальдо строки, «сумма детей = родитель», «Итого» = корень; счета без сальдо по аналитике (90, 91, 99) — вне тождества строки (`osv_checks.yaml`) |
|
||
| `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`; причины и номера строк — в `warnings[]` документа листа, коды `OSV_*`); структуру под сходимость парсер не подгоняет. Строка «Итого» не найдена — тоже `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 («Непредвиденная ошибка разбора: …») |
|
||
|
||
Диагностика, не влияющая на статус, — структурные предупреждения `{code, severity, message, locator?}`. Предупреждения
|
||
файла в `metrics.warnings`: пропущенный лист — `SHEET_OTHER` (`info`, `locator = sheet:<имя>`), несовпадение `file_type`
|
||
с содержимым — `FILE_TYPE_MISMATCH`, xls без кодовой страницы — `XLS_NO_CODEPAGE`. Предупреждения листа ОСВ — в
|
||
`warnings[]` его документа (`OSV_*`, `info`): стратегия иерархии (`OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`), свёрнутое сальдо,
|
||
нераспознанные числа с номером строки, непрочитанные поля шапки, «нет строк данных» (пустая ОСВ).
|
||
|
||
### Строки-показатели и колонка «Показатели» (§1.3, AURORA-1613, AURORA-1661)
|
||
|
||
Колонка «Показатели» (`service_columns.indicator`; заголовок часто «Показа-тели») несёт вид суммы строки: «БУ» — обычная
|
||
строка данных (`data_type = BU`), «НУ», «Вал.», «Кол.», «Кол. доп.» — показатели (`NU`/`CURRENCY`/`QUANTITY` по словарю
|
||
`data_types.yaml`: `indicator_column`), «ПР», «ВР» (`indicator_column_untyped`, `data_type = null`) и неизвестная метка — показатель с
|
||
`data_type = null` (неизвестная — ещё и предупреждение `OSV_ROWS`). Показатель — строка с признаком `indicator`: он
|
||
не входит в суммирование «сумма детей = родитель» и не проверяется как родитель, его родитель в дереве — предыдущая строка
|
||
данных (`indicators.py`, вне каскада иерархии); `row_value` строки с меткой колонки — сама метка, колонка остаётся в
|
||
`extra`. Строка «В валюте» (пары «Валюта <код>» / «В валюте» на одном уровне) — показатель `CURRENCY`, «Валюта <код>» —
|
||
обычная строка в рублях. Итоговые строки «Итого» по БУ и НУ входят в таблицу; сверка идёт с первой итоговой
|
||
строкой, не показателем. Если шапка не называет вид данных, строки без собственной метки получают `BU`, а в
|
||
`warnings[]` листа — `OSV_HEADER` «вид данных в шапке не указан — строки без метки приняты как БУ» (`header.data_type`
|
||
остаётся `null`).
|
||
|
||
### Контрольные суммы (§1.4)
|
||
|
||
Допуск ±0.01, пустое значение = 0. Строки-показатели (признак строки: НУ/ПР/ВР/Кол./Вал./«В валюте»/код валюты, метка колонки «Показатели» или
|
||
текст) в суммирование не входят. Нетривиальное правило — сверка «сумма детей = родитель»: **обороты** сверяются по колонкам, **сальдо** — по
|
||
колонкам либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С
|
||
показывает счёт нетто, а субсчета и контрагентов — развёрнуто (решение R-21, design №31). Остаточный риск: перестановка
|
||
сторон при совпавшем нетто родителя этой проверкой не ловится — её ловят тождество строки и «Итого = корень».
|
||
|
||
**«Итого».** Основная итоговая строка — первая TOTAL листа, не являющаяся показателем и не «Итого развернутое»; она сверяется с первым корнем ACCOUNT либо, если корней
|
||
уровня 0 несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03), с суммой строк уровня 0
|
||
(OQ-106, OQ-133; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя выше). Вторая итоговая
|
||
строка в итоговом блоке, и перед «Итого», и после неё («Итого развернутое», `total_row_secondary`), — тоже `TOTAL`, в сверку
|
||
«Итого» не входит. Тождество строки на ней пропускается, только если обороты пусты и на листе есть другая «Итого» (перед
|
||
«Итого» в 1С только развёрнутые сальдо); с оборотами или единственная итоговая — проверяется как обычная. Строка сумм
|
||
без подписи в конце таблицы (1С без «Итого») принимается за итоговую с предупреждением.
|
||
|
||
**ИНН контрагента** (OQ-100, OQ-130): `counterparty_inn` берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из
|
||
строки ИНН рядом с наименованием (любая из восьми раскладок, `inn_rows.py`, OQ-163: такая строка в `rows` не попадает, в
|
||
`warnings[]` документа (`OSV_INN_ROWS`) — «строка N: ИНН … отнесён к контрагенту в строке M»; правило OQ-130 «строка без сумм над названием»
|
||
заменено), затем из конца текста названия («Полиграф, 3528315710»,
|
||
«ООО Альфа (ИНН 7701234567)»; 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»). Из текста ИНН
|
||
отрезается всегда: `counterparty_name` без ИНН и разделителя, `row_value` — исходный текст. Только для строк
|
||
`COUNTERPARTY`; договоры и прочее не трогаются. Правила — `osv_headers.yaml`: `counterparty_inn_suffix`, `inn_only_row`,
|
||
`inn_kpp_cell`.
|
||
|
||
**Реквизит «Контрагенты.ИНН»** (OQ-162, #32): элемент `<группировка>.ИНН` в шапке — в варианте 2 («субконто: Контрагенты,
|
||
Контрагенты.ИНН») или строкой блока заголовка в варианте 1 — не группировка, а реквизит: в `group_schema` его нет, у владельца `has_inn = true`
|
||
(владелец и направление — `GroupSchema.inn_hint`). Подсказка для строк ИНН: порядок элементов задаёт направление (после владельца — ИНН под наименованием, перед — над) и снимает
|
||
проверку контрольной суммы. Склейку выполняет `inn_rows.py` (OQ-163): ИНН строки отдаётся наименованию (извлечение — как из
|
||
колонки ИНН, R-24), строка убирается, её дети переходят на уровень; предупреждение «ИНН … отнесён к контрагенту в строке …».
|
||
Пустая ячейка (физлицо без ИНН) — склейка без ИНН. Не склеилось (несколько строк ИНН, суммы не равны, ИНН не распознан, направление неизвестно) — строка `OTHER`,
|
||
предупреждение с причиной, документ `needs_check`; ИНН строки, расходящийся с ИНН колонки у наименования, не применяется —
|
||
остаётся ИНН колонки, предупреждение, `needs_check`; строка из 10/12 цифр с неверной контрольной суммой (без подсказки) остаётся
|
||
обычной строкой, «строка N: похожа на ИНН X, но контрольная сумма не сходится — строка оставлена как есть», `needs_check`; раскладка, противоречащая
|
||
подсказке шапки, склеивается с отдельным предупреждением и `needs_check`. Число из xls читается без «.0» у целых (`readers/_common.make_cell`).
|
||
|
||
**Период-диапазон.** «Октябрь 2024 г. – Сентябрь 2025 г.» (`periods.yaml`: `months`, `range`): январь–конец квартала —
|
||
нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter`
|
||
и `is_cumulative` = `null` и предупреждение «не с начала года» (OQ-131: ядро такой документ к версии не привязывает).
|
||
|
||
**Счета без сальдо по аналитике (OQ-132).** У 90, 91 и 99 строки аналитики несут только обороты, сальдо закрывается на
|
||
субсчёт закрытия (91.09), поэтому тождество строки на них не проверяется; иначе каждая ОСВ 91 получала бы `needs_check`.
|
||
Список счетов — данные: `accounts_without_analytic_balance` в `api/rules/osv_checks.yaml` (состав `90`, `91`, `99`;
|
||
счёт 90 добавлен решением владельца 19.10.2026, O-1 из #58). Строка относится к счёту списка по коду ближайшего счёта её ветки:
|
||
код совпадает с записью или является её субсчётом (`91`, `91.01`, но не `910`). Сверка «сумма детей = родитель» остаётся:
|
||
обороты — всегда, сальдо — только у родителя с дочерними субсчетами (корень → 91.01/91.02/91.09); у родителя с одной
|
||
аналитикой сальдо не сверяется. «Итого» сверяется как обычно.
|
||
|
||
### Словари и как добавить синоним
|
||
|
||
Словари — данные: `osv_headers.yaml` (признаки листов, группы сумм, «Дебет/Кредит», колонка ИНН, «Итого», метки шапки),
|
||
`osv_groups.yaml` (группировки → `ACCOUNT/DEPARTMENT/COUNTERPARTY/CONTRACT/OTHER`), `periods.yaml` (период → квартал и
|
||
нарастающий итог), `data_types.yaml` (БУ/НУ/сумма, строки «Вал.»/«Кол.»/«Кол-во, <единица>», коды и написания валют), `osv_checks.yaml` (счета без сальдо
|
||
по аналитике — вне тождества строки). Формат и правила
|
||
записи паттернов — [`api/rules/README.md`](api/rules/README.md).
|
||
|
||
Новый формат подключается строкой в YAML, без правки `src/`. Пример: выгрузка с заголовком «Нач. остаток» вместо «Сальдо на
|
||
начало» не распознаётся штатным словарём (результат `needs_check`, суммы начала уходят в `extra`). Добавьте паттерн в
|
||
`osv_headers.yaml`:
|
||
|
||
```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` без префикса; ссылки на символы (`2`) раскрывает парсер, `>` в кавычках тег не обрывает; площадь — по строке `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` — решения платформы (они же —
|
||
настоящая граница безопасности чтения). Книга в пределах лимитов читается библиотекой целиком.
|
||
|
||
## Парсер форм (ББ/ОФР)
|
||
|
||
Книга разбирается `book.py` (`BookParser`): листы ОСВ идут через `osv.py`, листы форм — через адаптер `statements/`
|
||
к пакету `arm_statements`. Документы результата: сначала ОСВ в порядке листов, затем формы в порядке листов.
|
||
|
||
**Словари форм и шаги 2–8 живут в пакете `arm_statements`** (копия `api/arm_statements/`, версия — `arm_statements/VERSION`,
|
||
`service.statements_version`). Собственного конвейера форм и словарей форм в сервисе нет: синонимы заголовков, периодов,
|
||
соотношения §7 — данные пакета (`rules/statement_*.yaml`), код сервиса их не содержит.
|
||
|
||
**Копию `api/arm_statements/` не правим напрямую.** Обновление — `docs/scripts/sync-statements.sh` (`--in-place` в PR
|
||
фичи); свежесть проверяет `make statements-check` (код возврата 20 при расхождении, входит в `make check-full`).
|
||
|
||
| Модуль (`api/src/parser/statements/`) | Что делает |
|
||
|---|---|
|
||
| `forms.py` | блоки форм на листе и вид листа по словарям пакета: заголовок формы или код ОКУД открывает блок; маркер «с.2», подпись «Код» с периодами или колонка кодов — продолжение |
|
||
| `cells.py` | ячейка Excel -> `arm_statements.Cell`: текст как его показывает Excel, значение, ссылка на ячейку; без нормализации |
|
||
| `header_cells.py` | шапка формы по меткам словаря пакета: ячейки-значения для `parse_header` (ИНН аудитора не берётся, организация — после метки «Организация», дата — из подзаголовка или тройки «число | месяц | год») |
|
||
| `adapter.py` | `plan_sheets` раскладывает листы по видам (ОСВ / форма / продолжение / прочее); `build_candidates` собирает кандидатов для пакета, лист-продолжение присоединяется к открытой форме |
|
||
| `pipeline.py` | `StatementPipeline`: кандидаты адаптера -> шаги 2–8 пакета (`normalize_candidate`) -> документы контракта через `to_contract`; предупреждения файла — `warning_to_contract` |
|
||
| `number_format.py` | отображаемое значение числа по числовому формату ячейки (`#,##0,` и т. п.; #42) |
|
||
|
||
Модули ОСВ (`osv.py`, `osv_warnings.py`) формы не затрагивают. Предупреждения листа ОСВ — коды `OSV_*` (уровень `info`,
|
||
контракт 2.0): `osv_warnings.py`; `XLS_NO_CODEPAGE` — предупреждение файла пакета.
|
||
|
||
Результат формы — контракт **2.0** (`CONTRACT_VERSION = "2.0"`, `src/contract/`): у документа `status` и `warnings`,
|
||
`checks` обязателен у BALANCE/INCOME со строками, `line_code` может быть пуст (`line_status`: `ok | unreadable | missing |
|
||
detail`). Статус книги и критерий `ok` документа вычисляет пакет (шаги 2–8); сервис статус не пересчитывает.
|
||
|
||
Словарь форм не правится в сервисе. Новое написание формы или соотношение — правка в `arm-statements` с последующим
|
||
`sync-statements`, плюс фикстура или синтетика, на которой оно срабатывает.
|
||
|
||
### Ограничения
|
||
|
||
- Единицы не нормализуются: `units` — текст шапки. Значение `amount` — **как его показывает документ** (#42): в xlsx/xls
|
||
применяется числовой формат ячейки (`statements/number_format.py`): `#,##0,` и `0,` делят на 1000 (рубли с копейками
|
||
из 1С становятся тысячами), `#,##0` округляет до целого (половина от нуля, как в Excel), условные секции
|
||
(`[=0]"-"`, `[=-646532]"(647)"`) дают прочерк или текст-литерал («(647)» -> -647). Формат `General` — сырое значение.
|
||
Формат, который не удалось разобрать (`%`, даты, дроби, экспонента), — сырое значение и одно предупреждение на
|
||
документ: `формат чисел не разобран, значения как в ячейках: «…»`. В xlsb числовые форматы не читаются (pyxlsb): значения
|
||
как в файле и одно предупреждение на документ `формат чисел xlsb не читается: значения как в ячейках` (`XLSB_NO_FORMATS`). Разбор ОСВ
|
||
формат ячейки не применяет. При «тыс. руб.» и доле значений (по показываемым) с дробной частью не меньше
|
||
`fractional_share_percent` выдаётся одно предупреждение на документ: `FRACTIONAL_IN_THOUSANDS` в xlsx/xls — на статус
|
||
не влияет, `XLSB_SCALE_SUSPECT` в xlsb (формат чисел не читается) — уровня `error`, документ `needs_check`; кроме того,
|
||
`needs_check` задают нарушенные соотношения (расхождение до `rounding_tolerance_percent` — мягкое нарушение, OQ-138).
|
||
- Страницы одной формы в разных файлах не склеиваются: каждая страница-продолжение без первой страницы остаётся
|
||
одиноким продолжением и даёт `needs_check`.
|
||
- Лист с 1–2 кодами форм (меньше `min_codes`) формой не считается, если идёт первым; после листа формы он берётся
|
||
как продолжение (`min_codes_continuation` словаря) и присоединяется, если все его коды принадлежат типу открытой формы
|
||
(число колонок сумм при этом не сверяется); лист с `min_codes` и более кодами без маркера присоединяется, только если
|
||
совпало и число колонок сумм (`sheet_joins` пакета). Иначе лист остаётся отдельным кандидатом с предупреждением
|
||
`SHEET_NOT_JOINED` (`error`): «лист «X» не присоединён к форме «Y»: коды или число колонок сумм не подходят»; книга
|
||
`needs_check`.
|
||
|
||
### Реальные формы: файл → статус
|
||
|
||
Источник — `api/tests/statements_manifest.yaml` (18 файлов `fixtures/statements-xlsx/**`). Статусы сверены прогоном.
|
||
|
||
| Файл | Документы и статус | Причина |
|
||
|---|---|---|
|
||
| `unagrande-co/2024-FY/Б.Б Лист 1.xls` | BALANCE `needs_check` | только актив: `1600 = 1700` не сходится |
|
||
| `unagrande-co/2024-FY/Б.Б. Лист 2.xls` | BALANCE `needs_check` | страница 2 без шапки |
|
||
| `unagrande-co/2024-FY/ОФР. Лист 1.xls` | INCOME `done` | значения по числовому формату (тыс. руб.); мягко расходится `2400 = 2300 - 2410 + 2420` (в пределах допуска, OQ-138) |
|
||
| `unagrande-co/2024-FY/ОФР. Лист 2 .xls` | INCOME `needs_check` | страница 2 без шапки |
|
||
| `unagrande-group/2024-FY/ББ. Лист 1 .xls` | BALANCE `needs_check` | только актив |
|
||
| `unagrande-group/2024-FY/ББ. Лист 2 (2).xls` | BALANCE `needs_check` | страница 2 без шапки |
|
||
| `unagrande-group/2024-FY/ОФР. Лист 2.xls` | INCOME `needs_check` | страница 2 без шапки, страницы 1 в пакете нет |
|
||
| `unagrande-group/2025-Q3/ББ. Лист 1 3кв.25.xls` | BALANCE `needs_check` | только актив |
|
||
| `unagrande-group/2025-Q3/ББ. Лист 2 3кв.25.xls` | BALANCE `needs_check` | страница 2 без шапки |
|
||
| `unagrande-group/2025-Q3/ОФР. Лист 1 3кв.25.xls` | INCOME `done` | значения по числовому формату (тыс. руб.); мягко расходятся `2400 = 2300 - 2410 + 2420` и `2410 = 2411 + 2412` (OQ-138) |
|
||
| `unagrande-group/2025-Q3/ОФР. Лист 2 3кв.25.xls` | INCOME `needs_check` | страница 2 без шапки |
|
||
| `npz/2025-Q3/Бухгалтерская отчетность … (НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД).xlsx` | BALANCE `done`, INCOME `done` | значения по числовому формату `#,##0,` (тыс. руб.); мягко расходятся детализации 1150, 1250, 1410, 1500, 1510 (ББ) и 2400, 2410 (ОФР), жёстких нет (OQ-138); лист с единственной строкой 2400 присоединён (O-9) |
|
||
| `mezheninovskaya/2025-Q3/1_Форма1_бух_09.xlsx` | BALANCE `done` | все соотношения сходятся |
|
||
| `mezheninovskaya/2025-Q3/1_Форма2_бух_09.xlsx` | INCOME `done` | все соотношения сходятся |
|
||
| `templates/ББ до 01.01.2025.xlsx` | BALANCE `needs_check`, DISCLOSURE `done` | шаблон без сумм и шапки; «Примечания» шаблона — пустой DISCLOSURE |
|
||
| `templates/ББ после 01.01.2025.xlsx` | BALANCE `needs_check` | шаблон |
|
||
| `templates/ОФР_до 01.01.2025.xlsx` | INCOME `needs_check` | шаблон |
|
||
| `templates/ОФР_после 01.01.2025.xlsx` | INCOME `needs_check` | шаблон |
|
||
|
||
Запуск на форме: `ENV_FILE=.env.test .venv/bin/python -m src.parser.cli "<путь к файлу>"` из `api/` (см. «Запуск на файле»);
|
||
в stdout — файл результата v2.0, у форм в `documents[]` — `header`, `lines[]`, `checks`.
|
||
|
||
## Тесты на реальных данных и 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-тест). Поэтому после намеренной правки парсера:
|
||
|
||
```bash
|
||
make golden-update # перезаписать снимки, diff изменившихся — в stdout
|
||
git -C ../fixtures diff # просмотреть каждое изменение: оно должно быть следствием правки
|
||
git -C ../fixtures add golden && git -C ../fixtures commit # снимки — отдельным коммитом в репозитории fixtures
|
||
```
|
||
|
||
Для форм — второй каталог: `fixtures/golden/expected_statement_parse/` (файлы `fixtures/statements-xlsx/**`, включая
|
||
`synthetic/` — синтетика `st01`–`st06`, генератор `tests/synthetic/generate_statements.py`); ожидания по реальным формам —
|
||
манифест `api/tests/statements_manifest.yaml`. Снимки обоих каталогов обновляет один `make golden-update`.
|
||
|
||
Править снимки вручную нельзя; новый файл в `osv/` без снимка — падение «нет снимка: выполните make golden-update».
|
||
Первый `make golden-update` для нового файла печатает в stdout файл снимка целиком (как «добавленные» строки diff),
|
||
для изменившегося — только diff; сводка «файлов / без изменений / новых / изменено / лишних» идёт в stderr. Снимки,
|
||
которым не соответствует ни один файл, считаются лишними: `make golden-update` их удаляет, `make golden-check`
|
||
завершается кодом 1.
|
||
Каталог `fixtures/osv/synthetic/` — синтетические файлы `s01`–`s36` (генератор — `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
|
||
|
||
```bash
|
||
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`](../docs/dev/README.md).
|
||
|
||
По умолчанию сервис слушает `0.0.0.0:8000` (`SERVER__HOST`, `SERVER__PORT`); `api/.env.example` сужает адрес до
|
||
`127.0.0.1:8000` (только loopback). Проверка:
|
||
|
||
```bash
|
||
curl -s localhost:8000/healthcheck
|
||
```
|
||
|
||
В ответе — `uptime`, `version`, `subjects` (используемые subject'ы: `tabular_request`, `result`; JWT там нет) и `nats`: `ok` — подключён, `down` — включён (`NATS_ENABLED=true`), но связи нет,
|
||
`off` — выключен (`NATS_ENABLED=false`, воркер не запущен).
|
||
|
||
Если NATS или MinIO недоступны при старте, приложение всё равно поднимается: ошибка логируется, соединение с NATS
|
||
восстанавливается автоматически (бесконечное автопереподключение).
|
||
|
||
## Запуск на файле
|
||
|
||
Разбор одного файла без NATS и S3 — из каталога `api/`:
|
||
|
||
```bash
|
||
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 файла результата контракта v2.0 — тот же, что воркер кладёт в 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`):
|
||
|
||
```json
|
||
{
|
||
"schema_version": "2.0",
|
||
"job_id": "00000000-0000-0000-0000-000000000000",
|
||
"file_id": "00000000-0000-0000-0000-000000000000",
|
||
"service": {
|
||
"name": "tabular-parser",
|
||
"version": "1.0.0",
|
||
"statements_version": "3390f92daddbfd4919d7f799ad2959cc35bfbd92"
|
||
},
|
||
"status": "done",
|
||
"metrics": {
|
||
"duration_ms": 3,
|
||
"rows": 9,
|
||
"warnings": []
|
||
},
|
||
"documents": [
|
||
{
|
||
"document_type": "OSV",
|
||
"locator": "sheet:Лист_1",
|
||
"status": "done",
|
||
"warnings": [
|
||
{
|
||
"code": "OSV_HIERARCHY_BY_OUTLINE",
|
||
"severity": "info",
|
||
"message": "иерархия строк — по группировке листа (outline)"
|
||
}
|
||
],
|
||
"header": {
|
||
"organization_name": "АО \"Русский бисквит\"",
|
||
"organization_inn": null,
|
||
"account_code_raw": "66",
|
||
"period_text": "2024 г.",
|
||
"period_year": 2024,
|
||
"period_quarter": "Q4",
|
||
"is_cumulative": true,
|
||
"period_start": "2024-01-01",
|
||
"period_end": "2024-12-31",
|
||
"is_from_year_start": true,
|
||
"data_type": "BU",
|
||
"currency": "RUB",
|
||
"header_variant": 1
|
||
},
|
||
```
|
||
|
||
## Переменные окружения
|
||
|
||
Настройки читает `pydantic-settings` (`api/src/core/config.py`). Вложенные поля задаются через двойное подчёркивание.
|
||
Файл окружения выбирается переменной `ENV_FILE` (по умолчанию `.env` в каталоге `api/`, тесты используют `.env.test`).
|
||
|
||
| Переменная | По умолчанию | Назначение |
|
||
|---|---|---|
|
||
| `OPENBAO__APP__<ИМЯ>` | — | **контур платформы:** туда передаются только секреты OpenBao (`OPENBAO__*`), обычные переменные окружения задать нельзя. Любая настройка верхнего уровня из этой таблицы (`PARSE_TIMEOUT_SECONDS`, `PARSE_MAX_BYTES`, `NATS_ENABLED`, `DEBUG`, …) может прийти как `OPENBAO__APP__<ИМЯ>`, например `OPENBAO__APP__PARSE_TIMEOUT_SECONDS=300`. Значения разбираются как из обычного env. Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию. Неизвестное имя — ошибка конфигурации при старте; `OPENBAO__APP__OPENBAO__…` не допускается. Читается только из окружения процесса (в `.env` не действует). Реализация — `api/src/core/settings_sources.py` (тот же файл в arm-core и arm-scan-gateway) |
|
||
| `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 |
|
||
| `OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT` | `files.tabular_uploaded` | subject запроса `tabular`, на который подписан сервис |
|
||
| `OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT` | `files.img_uploaded` | subject запроса `image` (слушает scan-gateway; здесь — единый набор ключей) |
|
||
| `OPENBAO__NATS__NATS_RESULT_SUBJECT` | `files.tabular_parsed` | subject, в который сервис публикует ack и результат |
|
||
| `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`, настройкой
|
||
не меняется.
|