Сценарии намеренно красные: воспроизводят баги AURORA-1613 и AURORA-1661. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
tabular-parser — разбор XLS/XLSX/XLSB для АРМ КА
Stateless-сервис распознавания табличных файлов (ОСВ по счетам, ББ, ОФР из Excel) для автоматизированного рабочего
места кредитного аналитика ДКБ (АО «Россельхозбанк»). Сервис не имеет доступа к БД и фронта и видит только две
внешние системы: NATS (core, без JetStream) и S3 (MinIO, один бакет). Он получает задание, читает исходный файл,
возвращает структуру файла и ничего не знает про организации, периметры и план счетов — нормализацией занимается
arm-core.
Обмен с arm-core идёт по контракту v2.0: схемы — ../docs/contracts/, копия — в
api/contracts/ (идентичность с источником проверяется в make check).
Срез 0 — скелет. Транспорт работает полностью (подписка, ack, чтение S3, публикация результата, ошибки транспорта);
разбор подключается через интерфейс Parser без изменений воркера.
Срез 2a — парсер ОСВ. По умолчанию (get_parser()) разбор делает OsvParser: оборотно-сальдовые ведомости по
счетам из XLS/XLSX/XLSB, структура определяется по содержимому, достоверность подтверждается контрольными суммами
(§1 05-domain-rules.md). Листы с формами ББ/ОФР пропускаются с предупреждением
(подсрез 2b). Заглушка NullParser (error = NOT_IMPLEMENTED) осталась только для тестов воркера. Устройство парсера —
в разделе «Парсер ОСВ» ниже.
Репозиторий — часть workspace АРМ КА. Общий контекст, модель, правила предметной области и контракты — в соседних репозиториях:
../CLAUDE.md— инварианты и процесс работы всего workspace;../docs/— контекст (context/), контракты (contracts/), окружение разработки (dev/);CLAUDE.md— соглашения этого репозитория.
Репозитории должны лежать рядом: tabular-parser/ и docs/ — соседние каталоги (так же устроено зеркало .wt/<срез>/).
Пути ../docs/... ниже и в Makefile рассчитаны на это.
Как обрабатывается запрос
arm-coreпубликует запрос в NATS, subjectfiles.tabular_uploaded(схемаrecognition-request), исходный файл лежит в S3 под ключомuploads/{file_id}/{job_id}/<имя>.- Сервис подписан на этот subject в queue group
tabular-parser— несколько экземпляров делят нагрузку между собой. - Запрос валидируется по схеме. Если
job_idещё не обработан этим процессом (process-local LRU), сервис до чтения файла публикует вfiles.tabular_parsedконвертstatus = processing(ack) с заголовкомNats-Msg-Id = ack:<job_id>:<attempt>. Повторный запрос с тем жеjob_idполучает повторный ack и второй раз не обрабатывается. - Файл читается из S3 (
s3.bucket/s3.key) и передаётся парсеру («байты + тип файла → документы, метрики или ошибка»). - Результат (схема
recognition-result) записывается в S3 под ключомresults/{file_id}/{job_id}/result.json. - В
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—make venvсоздаёт окружение и ставит зависимости черезuv;uvсам подберёт интерпретатор 3.11, системныйpython3другой версии не подойдёт. - Docker и compose v2 — для NATS и MinIO (compose лежит в
../docs/dev/; он же поднимает PostgreSQL дляarm-core, этому сервису БД не нужна). make.
Пакеты raisa_* из Nexus банка вне банка недоступны: make venv пропускает строки raisa_* в requirements.txt.
Команды make
Выполняются из корня репозитория; инструменты берутся из api/.venv.
| Команда | Что делает |
|---|---|
make venv |
создаёт api/.venv (Python 3.11) и ставит requirements.txt и requirements-dev.txt через uv |
make lint |
ruff check ., ruff format --check ., mypy src |
make test |
pytest -m "not integration" — без Docker; включает тесты маркера fixtures (golden, property, покрытие словарей), без каталога ../fixtures они пропускаются |
make test-integration |
pytest -m integration — против NATS и MinIO из compose |
make test-fixtures |
pytest -m fixtures — тесты на реальных ОСВ и синтетике из ../fixtures: golden-снимки, покрытие словарей, property-тест |
make golden-update |
перезаписывает golden-снимки в ../fixtures/golden/expected_osv_parse/ и печатает unified diff изменившихся |
make golden-check |
то же без записи; код возврата 1, если снимки расходятся с разбором |
make check |
make lint + make test (включая идентичность копии контракта с ../docs/contracts); с каталогом fixtures ≈ 95 с — из-за тестов на 82 файлах |
make check-full |
make check + make test-integration + docker build; запускать перед PR (нужен поднятый compose, make up) |
make up |
docker compose -f ../docs/dev/docker-compose.yml up -d --wait postgres nats minio |
Интеграционные тесты (make test-integration, make check-full) поднимают сервис в процессе теста и вступают в queue group tabular-parser на реальной шине: на шине compose не должно работать другого экземпляра tabular-parser (и наоборот: на время теста in-process воркер ответит ошибкой разбора (UNSUPPORTED_FORMAT / UNREADABLE) на реальные запросы других производителей).
Парсер ОСВ
Парсер — набор чистых функций над единой моделью листа; воркер знает только интерфейс Parser.parse(data, file_type) -> ParseOutcome. Структура ОСВ ищется по содержимому: ни букв колонок, ни номеров строк, ни имён листов в коде нет
(это проверяет AST-линтер, см. ниже), синонимы лежат в api/rules/*.yaml.
Модули (api/src/parser/)
Конвейер идёт сверху вниз; все модули, кроме grid.py и readers/, оперируют индексами из содержимого и не знают
координат.
| Модуль | Что делает |
|---|---|
grid.py |
модель листа: Cell, Sheet, MergedRange, column_letter (буква колонки — только для диагностики) |
readers/ |
чтение книги в Sheet: read_book(data, file_type) -> (листы, предупреждения); формат определяется по сигнатуре байтов (xlsx — openpyxl, xls — xlrd с formatting_info, xlsb — pyxlsb, без outline level); несовпадение с file_type — предупреждение; до библиотеки чтения — лимиты чтения (_limits.py, см. «Лимиты») |
rules.py |
загрузчик словарей rules/*.yaml в frozen-модели (pydantic), normalize_text, Pattern; ошибка словаря — RuntimeError с файлом и ключом при старте |
detect.py |
вид листа (classify_sheet: ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы (find_table_header: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) |
header.py |
шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок group_schema (варианты шапки 1 и 2) |
hierarchy.py |
каскад стратегий уровней строк: outline → indent → account_pattern → sums; trivial — меньше двух строк данных; выбранная стратегия пишется в warnings[] документа листа как OSV_HIERARCHY_BY_<СТРАТЕГИЯ> (info) |
numbers.py |
суммы в Decimal с двумя знаками (to_money), ИНН из текста ячейки (inn_digits); float в домене нет |
inn_rows.py |
строки ИНН рядом с контрагентом (восемь раскладок, контрольная сумма без подсказки шапки, противоречие подсказке, OQ-163); выполняется над деревом до классификации |
classify.py |
сбор строк таблицы (collect_body_rows: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, data_type, extra (classify_rows) |
checks.py |
контрольные суммы §1.4: тождество сальдо строки, «сумма детей = родитель», «Итого» = корень; счета без сальдо по аналитике (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.4)
Допуск ±0.01, пустое значение = 0. Строки «Вал.», «Кол.» и код валюты (data_type = CURRENCY | QUANTITY) в суммирование
не входят. Нетривиальное правило — сверка «сумма детей = родитель»: обороты сверяются по колонкам, сальдо — по
колонкам либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто дебет − кредит: 1С
показывает счёт нетто, а субсчета и контрагентов — развёрнуто (решение R-21, design №31). Остаточный риск: перестановка
сторон при совпавшем нетто родителя этой проверкой не ловится — её ловят тождество строки и «Итого = корень».
«Итого». Основная итоговая строка — первая TOTAL листа; она сверяется с первым корнем ACCOUNT либо, если корней
уровня 0 несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03), с суммой строк уровня 0
(OQ-106, OQ-133; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя выше). Вторая итоговая
строка сразу за «Итого» («Итого развернутое», total_row_secondary) — тоже TOTAL, в сверку не входит. Строка сумм
без подписи в конце таблицы (1С без «Итого») принимается за итоговую с предупреждением.
ИНН контрагента (OQ-100, OQ-130): counterparty_inn берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из
строки ИНН рядом с наименованием (любая из восьми раскладок, 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). У 91 и 99 строки аналитики несут только обороты, сальдо закрывается на
субсчёт закрытия (91.09), поэтому тождество строки на них не проверяется; иначе каждая ОСВ 91 получала бы needs_check.
Список счетов — данные: accounts_without_analytic_balance в api/rules/osv_checks.yaml (начальный состав 91, 99;
90 не входит — внутри года у его субсчетов сальдо есть). Строка относится к счёту списка по коду ближайшего счёта её ветки:
код совпадает с записью или является её субсчётом (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.
Новый формат подключается строкой в YAML, без правки src/. Пример: выгрузка с заголовком «Нач. остаток» вместо «Сальдо на
начало» не распознаётся штатным словарём (результат needs_check, суммы начала уходят в extra). Добавьте паттерн в
osv_headers.yaml:
groups:
opening:
- 'нач\.\s*остаток'
и положите файл в ../fixtures/osv/ (или синтетику в tests/synthetic/generate.py), затем make golden-update.
Этот сценарий проверяют test_default_dictionary_does_not_know_the_new_synonym (до правки) и
test_new_synonym_parses_without_code_changes (после правки временной копии словарей) в tests/unit/parser/test_book_parser.py.
Принцип: новый формат = фикстура + синоним, не новый if.
Лимиты
| Настройка | По умолчанию | Что делает |
|---|---|---|
PARSE_MAX_BYTES |
50 МиБ | файл больше → UNSUPPORTED_FORMAT, книга не открывается |
PARSE_TIMEOUT_SECONDS |
120 | кооперативный таймаут: проверяется между листами, в циклах по всему листу (поиск заголовка таблицы, дополнительные колонки, строки таблицы, стратегии иерархии) → TIMEOUT. Чтение книги библиотекой (openpyxl/xlrd/pyxlsb) он не прерывает |
Фазу чтения таймаут не ограничивает, а лимит размера файла не ограничивает распакованный объём, поэтому до вызова
библиотеки чтения действуют лимиты чтения (api/src/parser/readers/_limits.py, ruling R-23) → UNSUPPORTED_FORMAT:
| Константа | Значение | Что ограничивает |
|---|---|---|
ZIP_UNCOMPRESSED_LIMIT_FACTOR |
10 | xlsx/xlsb: сумма объявленных несжатых размеров записей zip ≤ 10 × PARSE_MAX_BYTES (реальные книги распаковываются в 1,4–7,7 раза) |
MERGED_AREA_LIMIT |
200 000 ячеек | суммарная площадь объединённых ячеек книги (реальный максимум 10 090): xlsx — до openpyxl, потоковым разбором expat всех XML-частей архива (см. ниже); любой формат — после чтения, по диапазонам, которые индекс Sheet раскладывает по ячейкам (площадь ≤ grid.MAX_INDEXED_AREA = 10 000; merged на весь столбец не считается — индекс его не раскладывает) |
LARGE_MERGED_COUNT_LIMIT |
1000 диапазонов | любой формат, после чтения: число крупных объединённых диапазонов листа (площадь > grid.MAX_INDEXED_AREA), которые индекс Sheet не раскладывает и перебирает линейно на каждое обращение (1000 — ≈ 31 мкс на обращение, 100 000 — ≈ 3,2 мс); в реальных книгах — единицы |
Любой реальный файл разбирается быстрее 5 с (измерено: максимум ≈ 0,6 с).
Скан объединённых ячеек xlsx (ruling R-25) повторяет то, что разложит openpyxl, с запасом в сторону отказа:
сканируется каждая некаталожная запись zip, без отбора по имени или началу содержимого (openpyxl идёт к листу по цели
связи в workbook.xml.rels, расширение части не проверяет, а начало XML можно отодвинуть пробелами; бинарную часть
expat отвергает на первых байтах, ошибка разбора части не *.xml лишь заканчивает её скан, находка I-1б); считается
элемент с локальным именем mergeCell в любом пространстве имён (openpyxl берёт дочерние mergeCell по локальному
имени) и только атрибут ref без префикса; ссылки на символы (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), но если на части не
*.xmlexpat скана остановится раньше, чем разбор 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-тест). Поэтому после намеренной правки парсера:
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/ — 19 синтетических файлов s01–s19 (генератор — tests/synthetic/generate.py):
форматы, которых нет в реальных ОСВ (шапка варианта 2, «Вал.»/«Кол.», xlsb, без outline level, текстовые числа и т. д.).
Дополнительно: tests/fixtures/test_rules_coverage.py требует, чтобы каждая запись словарей api/rules/*.yaml
сработала хотя бы на одном файле (мёртвый синоним — удалить или добавить синтетику, см. api/rules/README.md), а
tests/unit/parser/test_no_coordinates.py — AST-линтер «без координат» в src/parser/**.
Запуск локально против compose
make venv
cp ../docs/dev/.env.example ../docs/dev/.env # один раз: учётные данные и порты compose (в git не попадает)
make up # NATS (4222), MinIO (9000, консоль 9001), бакет arm-ka
cp api/.env.example api/.env # один раз: настройки сервиса (в git не попадает)
cd api && .venv/bin/python run_server.py
Значения из api/.env.example уже совпадают с ../docs/dev/.env (стандартные порты и учётные данные compose); править
api/.env нужно, только если вы меняли порты или пароли compose.
Подробности про compose, смену портов и сброс — ../docs/dev/README.md.
По умолчанию сервис слушает 0.0.0.0:8000 (SERVER__HOST, SERVER__PORT); api/.env.example сужает адрес до
127.0.0.1:8000 (только loopback). Проверка:
curl -s localhost:8000/healthcheck
В ответе — uptime, version, subjects (используемые subject'ы: tabular_request, result; JWT там нет) и nats: ok — подключён, down — включён (NATS_ENABLED=true), но связи нет,
off — выключен (NATS_ENABLED=false, воркер не запущен).
Если NATS или MinIO недоступны при старте, приложение всё равно поднимается: ошибка логируется, соединение с NATS восстанавливается автоматически (бесконечное автопереподключение).
Запуск на файле
Разбор одного файла без NATS и S3 — из каталога api/:
cd api
ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XLS|XLSX|XLSB]
Тип файла по умолчанию определяется по расширению (.xls, .xlsx, .xlsb, регистр не важен); --file-type его
переопределяет, а для неизвестного расширения обязателен. ENV_FILE=.env.test нужен, потому что импорт пакета src
читает настройки сервиса (OPENBAO__…); для разбора они не используются, подойдёт и api/.env (см. выше).
Симптом без окружения — ValidationError: OPENBAO … Field required при запуске: задайте ENV_FILE=.env.test.
В stdout печатается JSON файла результата контракта 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):
{
"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, настройкой
не меняется.