# 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` получает повторный 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::`. Публикация при неудаче повторяется до `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 МБ тегов `` — 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`, настройкой не меняется.