arm-tabular-parser/README.md
2026-10-20 13:06:35 +04:00

544 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` без префикса; ссылки на символы (`&#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` — решения платформы (они же —
настоящая граница безопасности чтения). Книга в пределах лимитов читается библиотекой целиком.
## Парсер форм (ББ/ОФР)
Книга разбирается `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`, настройкой
не меняется.