diff --git a/CLAUDE.md b/CLAUDE.md
index 727ba05..06274b3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -38,14 +38,55 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
`raisa_fastapi_protected_api`; шаблонный `README.md` заменён описанием сервиса.
**Добавлено** (в скобках — задача `openspec/changes/slice-0-skeleton/tasks.md`):
-- `api/src/parser/` — интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 и форм §12 — чистые функции над
- книгой Excel, Срезы 2+;
-- `api/src/contract/` — pydantic-модели контракта v1.0 (запрос, результат, конверт); `api/contracts/` — копии схем (идентичность с
+- `api/src/parser/` — интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 — Срез 2a (см. ниже), форм §12 —
+ Срез 2b;
+- `api/src/contract/` — pydantic-модели контракта v1.1 (запрос, результат, конверт); `api/contracts/` — копии схем (идентичность с
`../docs/contracts` проверяется в `make check`) — 3.1;
- `api/rules/` — README о назначении; сами словари `*.yaml` (в документах `rules/*.yaml` относительно `api/`)
- появятся в Срезе 2 — 4.1;
+ появились в Срезе 2a — 4.1;
- `api/src/core/nats.py` и `api/src/core/worker.py` — подписка queue group `tabular-parser` на
`recognition.tabular.request` → ack → S3 `uploads/` → парсер → S3 `results/` → `recognition.result` — 4.2–4.3;
- `api/pyproject.toml` (ruff, mypy) и `Makefile` — 2.1; `Dockerfile`, `api/.env.example`, `api/.env.test` — 2.2.
+## Добавлено в Срезе 2a (парсер ОСВ; `openspec/changes/slice-2a-osv-parser/`)
+
+**Модули `api/src/parser/`** (конвейер сверху вниз; подробности и статусы — `README.md`, раздел «Парсер ОСВ»):
+- `grid.py` + `readers/` — модель листа и чтение xlsx/xls/xlsb; единственное место, где допустимы индексы и буквы колонок
+ (`column_letter` — только диагностика). `read_book(data, file_type) -> (листы, предупреждения)`, формат — по сигнатуре.
+- `rules.py` — загрузчик словарей `api/rules/*.yaml`; `detect.py` — вид листа и заголовок таблицы; `header.py` — шапка
+ и `group_schema`; `hierarchy.py` — каскад `outline → indent → account_pattern → sums` (+ `trivial`); `numbers.py` —
+ `Decimal` и ИНН; `classify.py` — строки и группировки; `checks.py` — контрольные суммы §1.4; `osv.py` —
+ оркестратор `OsvParser`; `deadline.py` — кооперативный таймаут; `errors.py` — коды ошибок; `cli.py` — разбор файла.
+- `get_parser(settings)` создаёт `OsvParser` один раз при старте (кэша нет); `NullParser` — только для тестов воркера.
+
+**Правила расширения:**
+- Новый формат ОСВ = фикстура в `../fixtures/osv/` (или синтетика `s1x_…` в `tests/synthetic/generate.py`) + синоним в
+ YAML. Не новый `if` под файл, организацию, имя листа или номер строки; «нужна координата» означает, что алгоритм не по
+ содержимому — остановиться и обсудить.
+- Нет координат: AST-линтер `tests/unit/parser/test_no_coordinates.py` проверяет `src/parser/**` (кроме `grid.py` и
+ `readers/`): литералы букв колонок и адресов ячеек, имена листов по умолчанию, сравнения номера строки с числом,
+ обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают.
+- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
+ (`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой.
+- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 82 файла: 66 реальных + 16 синтетических) — регрессия, а не
+ оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
+ проверяют property-тест и ручная сверка «Итого».
+- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не
+ подгонять.
+
+**Нетривиальное правило §1.4 (R-21):** при сверке «сумма детей = родитель» обороты сравниваются по колонкам, а сальдо —
+по колонкам либо по нетто `дебет − кредит`, если у родителя сальдо свёрнуто (1С показывает счёт нетто, а субсчета и
+контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения.
+
+**Настройки:** `PARSE_MAX_BYTES` (по умолчанию 50 МиБ; больше — `UNSUPPORTED_FORMAT`) и `PARSE_TIMEOUT_SECONDS` (120 с;
+кооперативный, → `TIMEOUT`) — `core/config.py`. Фазу чтения таймаут не прерывает: до библиотеки чтения действуют
+лимиты `readers/_limits.py` (распакованный zip ≤ 10 × `PARSE_MAX_BYTES`, площадь объединённых ячеек ≤ 200 000; R-23).
+
+**Контракт v1.1:** `rows[].extra` (нераспознанные колонки таблицы по заголовку); `CONTRACT_VERSION = "1.1"`; копия схемы
+в `api/contracts/` идентична `../docs/contracts/` (проверяется в `make check`).
+
+**Тесты:** `make check` ≈ 95 с, потому что `make test` (`-m "not integration"`) включает тесты маркера `fixtures` (golden,
+property, покрытие словарей); без каталога `../fixtures` они пропускаются. Полный набор — `make check-full` (+ интеграция
+с compose и сборка образа).
+
Проверки: `make check` (`pytest`, `ruff check . && ruff format --check .`, `mypy src`), `make check-full` перед PR.
diff --git a/Makefile b/Makefile
index a3a946f..3ce5026 100644
--- a/Makefile
+++ b/Makefile
@@ -3,7 +3,7 @@ VENV := api/.venv
BIN := $(CURDIR)/$(VENV)/bin
COMPOSE_FILE ?= ../docs/dev/docker-compose.yml
-.PHONY: venv lint test test-integration check docker-build check-full up
+.PHONY: venv lint test test-integration test-fixtures golden-update golden-check check docker-build check-full up
# Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их
venv:
@@ -22,6 +22,19 @@ test:
test-integration:
cd api && $(BIN)/pytest -m integration
+# Тесты на реальных данных из ../fixtures (ARM_KA_FIXTURES): golden-снимки, покрытие словарей, property-тест
+test-fixtures:
+ cd api && $(BIN)/pytest -m fixtures
+
+# Golden-снимки разбора ОСВ: перезапись в ../fixtures/golden/expected_osv_parse/ + unified diff изменившихся.
+# Результат обязательно просмотреть и коммитить в репозиторий fixtures отдельным коммитом (снимок — регрессия, не оракул)
+golden-update:
+ cd api && ENV_FILE=.env.test $(BIN)/python scripts/golden_update.py
+
+# То же без записи: код возврата 1, если снимки расходятся с разбором
+golden-check:
+ cd api && ENV_FILE=.env.test $(BIN)/python scripts/golden_update.py --check
+
check: lint test
# Сборка образа из корня репозитория
diff --git a/README.md b/README.md
index 20c4885..1272ef3 100644
--- a/README.md
+++ b/README.md
@@ -6,13 +6,17 @@ Stateless-сервис распознавания табличных файло
возвращает структуру файла и ничего не знает про организации, периметры и план счетов — нормализацией занимается
`arm-core`.
-Обмен с `arm-core` идёт по контракту **v1.0**: схемы — [`../docs/contracts/`](../docs/contracts/), копия — в
+Обмен с `arm-core` идёт по контракту **v1.1**: схемы — [`../docs/contracts/`](../docs/contracts/), копия — в
`api/contracts/` (идентичность с источником проверяется в `make check`).
-**Срез 0 — скелет.** Транспорт работает полностью (подписка, ack, чтение S3, публикация результата, ошибки транспорта),
-а сам разбор заменён заглушкой: на любой корректный запрос сервис отвечает конвертом `status = error` с
-`error.code = NOT_IMPLEMENTED`. Настоящий парсер (ОСВ — Срез 2, формы ББ/ОФР — позже) подключается через интерфейс
-`Parser` без изменений воркера.
+**Срез 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 АРМ КА. Общий контекст, модель, правила предметной области и контракты — в соседних
репозиториях:
@@ -40,8 +44,9 @@ Stateless-сервис распознавания табличных файло
при неудаче повторяется до `PUBLISH_RETRIES` раз (после первой попытки, с паузой 2 с).
Ошибки транспорта процесс не роняют, а превращаются в конверт `status = error`: нечитаемый или несовместимый запрос —
-`SCHEMA_MISMATCH` (ack при этом не публикуется), объект S3 не читается — `FILE_READ_ERROR`, исключение парсера —
-`UNKNOWN_ERROR`, заглушка парсера в Срезе 0 — `NOT_IMPLEMENTED`.
+`SCHEMA_MISMATCH` (ack при этом не публикуется), объект S3 не читается — `FILE_READ_ERROR`, исключение, вышедшее из
+парсера, — `UNKNOWN_ERROR`. Ожидаемые сбои разбора (не тот формат, нет листа ОСВ, лимит размера или времени) парсер
+возвращает сам исходом `status = error` с кодом контракта — см. «Статусы и коды» ниже.
## Состав репозитория
@@ -51,16 +56,18 @@ api/ бэкенд (все команды разработки
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/ интерфейс Parser и заглушка NullParser (разбор — Срез 2)
- src/contract/ pydantic-модели контракта v1.0
+ src/parser/ парсер ОСВ (Срез 2a): интерфейс Parser, OsvParser, читатели, детекторы, иерархия, классификация,
+ контрольные суммы, CLI; заглушка NullParser — для тестов воркера (модули — ниже)
+ src/contract/ pydantic-модели контракта v1.1
contracts/ копии JSON-схем контракта и примеры
- rules/ словари синонимов *.yaml для парсера (пока только README; словари — Срез 2)
- tests/ pytest: unit и integration (маркер `integration`)
+ 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-0-skeleton/)
+openspec/ требования и дельта текущего среза (openspec/changes/slice-2a-osv-parser/)
```
## Предварительные требования
@@ -81,13 +88,174 @@ openspec/ требования и дельта текущего ср
|---|---|
| `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 |
+| `make test` | `pytest -m "not integration"` — без Docker; включает тесты маркера `fixtures` (golden, property, покрытие словарей), без каталога `../fixtures` они пропускаются |
| `make test-integration` | `pytest -m integration` — против NATS и MinIO из compose |
-| `make check` | `make lint` + `make test` (включая идентичность копии контракта с `../docs/contracts`) |
-| `make check-full` | `make check` + `make test-integration` + `docker build`; запускать перед PR |
+| `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 воркер ответит `NOT_IMPLEMENTED` на реальные запросы других производителей).
+Интеграционные тесты (`make test-integration`, `make check-full`) поднимают сервис в процессе теста и вступают в queue group `tabular-parser` на реальной шине: на шине compose не должно работать другого экземпляра tabular-parser (и наоборот: на время теста in-process воркер ответит ошибкой разбора (`UNSUPPORTED_FORMAT` / `UNREADABLE`) на реальные запросы других производителей).
+
+## Парсер ОСВ
+
+Парсер — набор чистых функций над единой моделью листа; воркер знает только интерфейс `Parser.parse(data, file_type) ->
+ParseOutcome`. Структура ОСВ ищется **по содержимому**: ни букв колонок, ни номеров строк, ни имён листов в коде нет
+(это проверяет AST-линтер, см. ниже), синонимы лежат в `api/rules/*.yaml`.
+
+### Модули (`api/src/parser/`)
+
+Конвейер идёт сверху вниз; все модули, кроме `grid.py` и `readers/`, оперируют индексами из содержимого и не знают
+координат.
+
+| Модуль | Что делает |
+|---|---|
+| `grid.py` | модель листа: `Cell`, `Sheet`, `MergedRange`, `column_letter` (буква колонки — только для диагностики) |
+| `readers/` | чтение книги в `Sheet`: `read_book(data, file_type) -> (листы, предупреждения)`; формат определяется по сигнатуре байтов (xlsx — openpyxl, xls — xlrd с `formatting_info`, xlsb — pyxlsb, без outline level); несовпадение с `file_type` — предупреждение; до библиотеки чтения — лимиты чтения (`_limits.py`, см. «Лимиты») |
+| `rules.py` | загрузчик словарей `rules/*.yaml` в frozen-модели (pydantic), `normalize_text`, `Pattern`; ошибка словаря — `RuntimeError` с файлом и ключом при старте |
+| `detect.py` | вид листа (`classify_sheet`: ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы (`find_table_header`: 1–4 строки, шесть колонок сумм, колонка текста, ИНН, дополнительные колонки, правая граница, повтор заголовка внутри данных) |
+| `header.py` | шапка документа (организация, счёт, период, тип данных, валюта) и схема группировок `group_schema` (варианты шапки 1 и 2) |
+| `hierarchy.py` | каскад стратегий уровней строк: `outline` → `indent` → `account_pattern` → `sums`; `trivial` — меньше двух строк данных; выбранная стратегия пишется в `metrics.warnings` как `hierarchy=<имя>` |
+| `numbers.py` | суммы в `Decimal` с двумя знаками (`to_money`), ИНН из текста ячейки (`inn_digits`); `float` в домене нет |
+| `classify.py` | сбор строк таблицы (`collect_body_rows`: «Итого», свёрнутое сальдо) и тип группировки, счёт, контрагент, `data_type`, `extra` (`classify_rows`) |
+| `checks.py` | контрольные суммы §1.4: тождество сальдо строки, «сумма детей = родитель», «Итого» = корень |
+| `osv.py` | оркестратор `OsvParser`: лимит размера → чтение (с лимитами чтения) → лист за листом → статус и метрики |
+| `deadline.py` | кооперативный таймаут: шаги сами вызывают `Deadline.check` (поток прервать нельзя) |
+| `errors.py` | ожидаемые сбои с кодом контракта (`UnreadableError`, `UnsupportedFormatError`, `InvalidFormatError`, `InvalidDocumentError`, `ParseTimeoutError`) |
+| `cli.py` | разбор одного файла из командной строки (см. «Запуск на файле») |
+| `__init__.py` | интерфейс `Parser`, `ParseOutcome`, `NullParser`, `get_parser(settings)` |
+
+`get_parser(settings)` создаёт `OsvParser` один раз при старте приложения (кэша нет); словари читаются и проверяются
+там же, поэтому ошибка в YAML останавливает старт, а не падает на первом файле.
+
+### Статусы и коды
+
+`documents` — по одному `osvDocument` на лист ОСВ (`locator = sheet:<имя>`). Строка «Итого» входит в `rows` как
+`group_type = TOTAL` (`ASSUMPTION(OQ-01)`: хранить ли её, решает ядро); `metrics.rows` считает строки данных без неё.
+
+| Исход | Когда |
+|---|---|
+| `done` | контрольные суммы §1.4 сошлись на всех листах |
+| `needs_check` | нарушены контрольные суммы хотя бы на одном листе (`checks`: `total_row_matches_root`, `balance_identity_violations`, `parent_sum_violations`; причины и номера строк — в `metrics.warnings`); структуру под сходимость парсер не подгоняет. Строка «Итого» не найдена — тоже `needs_check` |
+| `error` / `INVALID_DOCUMENT` | в книге нет ни одного листа ОСВ (в сообщении — число просмотренных листов) |
+| `error` / `INVALID_FORMAT` | лист ОСВ найден, но нет заголовка таблицы, обязательной группы сумм («обороты», «сальдо на конец» — отсутствовать может только «начало») или не удалось определить иерархию; в сообщении — лист и шаг |
+| `error` / `UNREADABLE` | книга не открывается (повреждённый zip/BIFF); пишется в лог уровня WARNING |
+| `error` / `UNSUPPORTED_FORMAT` | сигнатура не XLS/XLSX/XLSB, размер больше `PARSE_MAX_BYTES` либо превышен лимит чтения (распакованный zip, площадь объединённых ячеек — см. «Лимиты») |
+| `error` / `TIMEOUT` | превышен `PARSE_TIMEOUT_SECONDS` |
+| `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение`; стек — в лог уровня ERROR («Непредвиденная ошибка разбора: …») |
+
+В `metrics.warnings` — диагностика, не влияющая на статус: `sheet=<имя> hierarchy=<стратегия>`, пропущенные листы
+(`skipped: statement (slice 2b)`, `skipped: нет признаков ОСВ`), несовпадение `file_type` с содержимым, свёрнутое
+сальдо, нераспознанные числа с номером строки, непрочитанные поля шапки, «нет строк данных» (пустая ОСВ).
+
+### Контрольные суммы (§1.4)
+
+Допуск ±0.01, пустое значение = 0. Строки «Вал.», «Кол.» и код валюты (`data_type = CURRENCY | QUANTITY`) в суммирование
+не входят. Нетривиальное правило — сверка «сумма детей = родитель»: **обороты** сверяются по колонкам, **сальдо** — по
+колонкам либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С
+показывает счёт нетто, а субсчета и контрагентов — развёрнуто (решение R-21, design №31). Остаточный риск: перестановка
+сторон при совпавшем нетто родителя этой проверкой не ловится — её ловят тождество строки и «Итого = корень».
+
+### Словари и как добавить синоним
+
+Словари — данные: `osv_headers.yaml` (признаки листов, группы сумм, «Дебет/Кредит», колонка ИНН, «Итого», метки шапки),
+`osv_groups.yaml` (группировки → `ACCOUNT/DEPARTMENT/COUNTERPARTY/CONTRACT/OTHER`), `periods.yaml` (период → квартал и
+нарастающий итог), `data_types.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_osv_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` — решения платформы (они же —
+настоящая граница безопасности чтения). Книга в пределах лимитов читается библиотекой целиком.
+
+## Тесты на реальных данных и 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
+```
+
+Править снимки вручную нельзя; новый файл в `osv/` без снимка — падение «нет снимка: выполните make golden-update».
+Первый `make golden-update` для нового файла печатает в stdout файл снимка целиком (как «добавленные» строки diff),
+для изменившегося — только diff; сводка «файлов / без изменений / новых / изменено / лишних» идёт в stderr. Снимки,
+которым не соответствует ни один файл, считаются лишними: `make golden-update` их удаляет, `make golden-check`
+завершается кодом 1.
+Каталог `fixtures/osv/synthetic/` — 16 синтетических файлов `s01`–`s16` (генератор — `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
@@ -116,6 +284,70 @@ curl -s localhost:8000/healthcheck
Если 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 файла результата контракта v1.1 — тот же, что воркер кладёт в S3 под
+`results/{file_id}/{job_id}/result.json` (`job_id` и `file_id` — нулевые заглушки). Формат вывода: UTF-8 без
+экранирования кириллицы, отступ 2 пробела, порядок полей — как в модели контракта, суммы — строки. `duration_ms` —
+фактическая, поэтому меняется от запуска к запуску. При `status = error` печатается `{"status": "error", "error": {...}}`,
+а код и сообщение ошибки дублируются в stderr.
+
+| Код возврата | Значение |
+|---|---|
+| `0` | `done` |
+| `1` | `needs_check` — нарушены контрольные суммы (§1.4) |
+| `2` | `error` — разбор вернул ошибку (`error.code`, `error.message`) |
+| `3` | файл не найден / не читается / ошибка использования (нет аргумента, неизвестное расширение без `--file-type`, недопустимое значение `--file-type`); у `argparse` код 2 по умолчанию, здесь он занят статусом `error` |
+
+Пример (начало вывода на `ОСВ_66 за 2024.xls` из `fixtures`):
+
+```json
+{
+ "schema_version": "1.1",
+ "job_id": "00000000-0000-0000-0000-000000000000",
+ "file_id": "00000000-0000-0000-0000-000000000000",
+ "service": {
+ "name": "tabular-parser",
+ "version": "1.0.0"
+ },
+ "status": "done",
+ "metrics": {
+ "duration_ms": 2,
+ "rows": 9,
+ "warnings": [
+ "sheet=Лист_1 hierarchy=outline"
+ ]
+ },
+ "documents": [
+ {
+ "document_type": "OSV",
+ "locator": "sheet:Лист_1",
+ "header": {
+ "organization_name": "АО \"Русский бисквит\"",
+ "organization_inn": null,
+ "account_code_raw": "66",
+ "period_text": "2024 г.",
+ "period_year": 2024,
+ "period_quarter": "Q4",
+ "is_cumulative": true,
+ "data_type": "BU",
+ "currency": "RUB",
+ "header_variant": 1
+ },
+```
+
## Переменные окружения
Настройки читает `pydantic-settings` (`api/src/core/config.py`). Вложенные поля задаются через двойное подчёркивание.
@@ -137,6 +369,8 @@ curl -s localhost:8000/healthcheck
| `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-сервера |
diff --git a/api/.env.example b/api/.env.example
index 8614ffe..2f5738d 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -36,3 +36,7 @@ WORKER_CONCURRENCY=1
PUBLISH_RETRIES=3
# Размер process-local LRU обработанных job_id (защита от дублей доставки)
DONE_CACHE_SIZE=1000
+# Лимиты разбора: размер файла в байтах (больше — UNSUPPORTED_FORMAT, книга не открывается; 52428800 = 50 МиБ)
+# и время разбора одного файла в секундах (больше — TIMEOUT); оба ниже reply_deadline ядра (5 мин)
+PARSE_MAX_BYTES=52428800
+PARSE_TIMEOUT_SECONDS=120
diff --git a/api/.env.test b/api/.env.test
index 099d508..16a4d00 100644
--- a/api/.env.test
+++ b/api/.env.test
@@ -24,3 +24,6 @@ NATS_ENABLED=false
WORKER_CONCURRENCY=1
PUBLISH_RETRIES=3
DONE_CACHE_SIZE=1000
+# Лимиты разбора (design D-8): значения по умолчанию
+PARSE_MAX_BYTES=52428800
+PARSE_TIMEOUT_SECONDS=120
diff --git a/api/contracts/examples/result-osv-minimal.json b/api/contracts/examples/result-osv-minimal.json
index 6b6d122..f883a8d 100644
--- a/api/contracts/examples/result-osv-minimal.json
+++ b/api/contracts/examples/result-osv-minimal.json
@@ -1,5 +1,5 @@
{
- "schema_version": "1.0",
+ "schema_version": "1.1",
"job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
"file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
"service": { "name": "tabular-parser", "version": "0.1.0" },
@@ -66,7 +66,8 @@
"turnover_debit": "500.00",
"turnover_credit": "200.50",
"closing_debit": "0.00",
- "closing_credit": "700.50"
+ "closing_credit": "700.50",
+ "extra": { "КПП": "352801001" }
}
],
"checks": {
diff --git a/api/contracts/examples/result-osv-needs-check.json b/api/contracts/examples/result-osv-needs-check.json
new file mode 100644
index 0000000..81d64a8
--- /dev/null
+++ b/api/contracts/examples/result-osv-needs-check.json
@@ -0,0 +1,125 @@
+{
+ "schema_version": "1.1",
+ "job_id": "3f2a9c1e-7b4d-4e0a-9d51-2c8e6a1b0f01",
+ "file_id": "a1c4e7d2-5b36-4f89-8e12-9d0c3b7a4e02",
+ "service": { "name": "tabular-parser", "version": "1.0.0" },
+ "status": "needs_check",
+ "metrics": {
+ "rows": 3,
+ "warnings": [
+ "sheet=Лист_1 hierarchy=outline",
+ "§1.4: сальдо на начало + обороты ≠ сальдо на конец (±0.01): строки 3"
+ ]
+ },
+ "documents": [
+ {
+ "document_type": "OSV",
+ "locator": "sheet:Лист_1",
+ "header": {
+ "organization_name": "ООО Ромашка",
+ "organization_inn": null,
+ "account_code_raw": "60",
+ "period_text": "1 квартал 2025 г.",
+ "period_year": 2025,
+ "period_quarter": "Q1",
+ "is_cumulative": false,
+ "data_type": "BU",
+ "currency": "RUB",
+ "header_variant": 1
+ },
+ "group_schema": [
+ { "type": "ACCOUNT", "name": "Счет" },
+ { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true }
+ ],
+ "columns": {
+ "opening_debit": "D",
+ "opening_credit": "E",
+ "turnover_debit": "F",
+ "turnover_credit": "G",
+ "closing_debit": "H",
+ "closing_credit": "I",
+ "inn": "B"
+ },
+ "rows": [
+ {
+ "row_number": 1,
+ "level": 0,
+ "parent_row_number": null,
+ "row_value": "60",
+ "group_type": "ACCOUNT",
+ "group_name": "Счет",
+ "account_code_raw": "60",
+ "counterparty_name": null,
+ "counterparty_inn": null,
+ "data_type": "BU",
+ "opening_debit": "0.00",
+ "opening_credit": "1000.00",
+ "turnover_debit": "500.00",
+ "turnover_credit": "200.50",
+ "closing_debit": "0.00",
+ "closing_credit": "700.50"
+ },
+ {
+ "row_number": 2,
+ "level": 1,
+ "parent_row_number": 1,
+ "row_value": "ООО Лютик",
+ "group_type": "COUNTERPARTY",
+ "group_name": "Контрагенты",
+ "account_code_raw": "60",
+ "counterparty_name": "ООО Лютик",
+ "counterparty_inn": "7711111111",
+ "data_type": "BU",
+ "opening_debit": "0.00",
+ "opening_credit": "1000.00",
+ "turnover_debit": "500.00",
+ "turnover_credit": "200.50",
+ "closing_debit": "0.00",
+ "closing_credit": "700.50",
+ "extra": { "КПП": "352801001" }
+ },
+ {
+ "row_number": 3,
+ "level": 2,
+ "parent_row_number": 2,
+ "row_value": "Вал.",
+ "group_type": "OTHER",
+ "group_name": null,
+ "account_code_raw": "60",
+ "counterparty_name": null,
+ "counterparty_inn": null,
+ "data_type": "CURRENCY",
+ "opening_debit": "0.00",
+ "opening_credit": "400.00",
+ "turnover_debit": "100.00",
+ "turnover_credit": "50.00",
+ "closing_debit": "0.00",
+ "closing_credit": "400.00"
+ },
+ {
+ "row_number": 4,
+ "level": 0,
+ "parent_row_number": null,
+ "row_value": "Итого",
+ "group_type": "TOTAL",
+ "group_name": null,
+ "account_code_raw": null,
+ "counterparty_name": null,
+ "counterparty_inn": null,
+ "data_type": "BU",
+ "opening_debit": "0.00",
+ "opening_credit": "1000.00",
+ "turnover_debit": "500.00",
+ "turnover_credit": "200.50",
+ "closing_debit": "0.00",
+ "closing_credit": "700.50"
+ }
+ ],
+ "checks": {
+ "total_row_matches_root": true,
+ "balance_identity_violations": [3],
+ "parent_sum_violations": []
+ }
+ }
+ ]
+}
diff --git a/api/contracts/recognition-result.schema.json b/api/contracts/recognition-result.schema.json
index c5eea87..5d329a8 100644
--- a/api/contracts/recognition-result.schema.json
+++ b/api/contracts/recognition-result.schema.json
@@ -1,13 +1,13 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "arm-ka/recognition-result/v1",
- "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.0",
+ "title": "Результат распознавания файла (tabular-parser / scan-gateway), контракт v1.1",
"description": "Один файл (stored_file) → один job → N документов. Сервисы не знают БД: только структура файла. Нормализация счетов, резолв контрагентов, проверки — в arm-core.",
"$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.",
"type": "object",
"required": ["schema_version", "job_id", "file_id", "service", "status", "documents"],
"properties": {
- "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.0 пишет \"1.0\"" },
+ "schema_version": { "type": "string", "pattern": "^1\\.\\d+$", "description": "MAJOR.MINOR; производитель контракта v1.1 пишет \"1.1\"" },
"job_id": { "type": "string", "format": "uuid" },
"file_id": { "type": "string", "format": "uuid" },
"service": {
@@ -114,7 +114,8 @@
"turnover_debit": { "$ref": "#/$defs/money" },
"turnover_credit": { "$ref": "#/$defs/money" },
"closing_debit": { "$ref": "#/$defs/money" },
- "closing_credit": { "$ref": "#/$defs/money" }
+ "closing_credit": { "$ref": "#/$defs/money" },
+ "extra": { "type": ["object", "null"], "description": "нераспознанные колонки внутри таблицы: заголовок колонки → текст ячейки (§1.2)", "additionalProperties": { "type": ["string", "null"] } }
}
}
},
diff --git a/api/pyproject.toml b/api/pyproject.toml
index 53b4910..28a74f1 100644
--- a/api/pyproject.toml
+++ b/api/pyproject.toml
@@ -28,3 +28,13 @@ ignore_missing_imports = true
module = ["miniopy_async", "miniopy_async.*"]
follow_imports = "skip"
ignore_missing_imports = true
+
+# Библиотеки чтения Excel и YAML (словари `rules/*.yaml`): без типов в поставке
+[[tool.mypy.overrides]]
+module = ["openpyxl", "openpyxl.*", "xlrd", "xlrd.*", "pyxlsb", "pyxlsb.*", "yaml", "yaml.*"]
+ignore_missing_imports = true
+
+# Строгая типизация парсера (чистые функции над книгой Excel)
+[[tool.mypy.overrides]]
+module = ["src.parser", "src.parser.*"]
+disallow_untyped_defs = true
diff --git a/api/requirements.txt b/api/requirements.txt
index baf800b..4050601 100644
--- a/api/requirements.txt
+++ b/api/requirements.txt
@@ -11,6 +11,7 @@ cffi==2.1.0
click==8.4.2
dnspython==2.8.0
email-validator==2.3.0
+et_xmlfile==2.0.0
fastapi==0.135.1
fastapi-cli==0.0.24
fastapi-cloud-cli==0.14.1
@@ -30,6 +31,7 @@ miniopy-async==1.23.4
multidict==6.7.1
nats-py==2.14.0
nkeys==0.2.1
+openpyxl==3.1.5
propcache==0.5.2
pycparser==3.0
pycryptodome==3.23.0
@@ -41,6 +43,7 @@ Pygments==2.20.0
PyNaCl==1.5.0
python-dotenv==1.2.2
python-multipart==0.0.32
+pyxlsb==1.0.10
PyYAML==6.0.3
rich==15.0.0
rich-toolkit==0.19.7
@@ -56,4 +59,5 @@ uvicorn==0.46.0
uvloop==0.22.1
watchfiles==1.2.0
websockets==16.1.1
+xlrd==2.0.2
yarl==1.24.5
diff --git a/api/rules/README.md b/api/rules/README.md
index e58ac19..83f5609 100644
--- a/api/rules/README.md
+++ b/api/rules/README.md
@@ -1,11 +1,60 @@
# rules/ — словари синонимов парсера
-Каталог для словарей `rules/*.yaml`, по которым `tabular-parser` ищет структуру файла по содержимому: заголовки
-колонок, группировки, коды строк, названия форм. Словари — **данные, а не код**: новый синоним добавляется строкой в
-YAML без правки парсера. Парсер не привязан к координатам (буквы колонок, номера строк, имена листов).
+Словари `rules/*.yaml`, по которым `tabular-parser` ищет структуру файла по содержимому: заголовки колонок,
+группировки, периоды, типы данных. Словари — **данные, а не код**: новый синоним добавляется строкой в YAML без
+правки парсера. Парсер не привязан к координатам (буквы колонок, номера строк, имена листов); это проверяет
+AST-линтер `tests/unit/parser/test_no_coordinates.py`.
-В Срезе 0 каталог пуст: парсер — заглушка (`NullParser`, код `NOT_IMPLEMENTED`), словарей нет. Первые словари появятся в
-Срезе 2 вместе с реализацией разбора ОСВ / ББ / ОФР (см. `docs/context/05-domain-rules.md` §1.0, §12 в репозитории
-`docs`).
+Путь указан относительно `api/` (рабочего каталога команд сервиса): `api/rules/`. Загрузчик — `src/parser/rules.py`
+(`load_rules()`), источник содержимого — `docs/context/05-domain-rules.md` §1.0–§1.5.
-Путь указан относительно `api/` (рабочего каталога команд сервиса): `api/rules/`.
+| Файл | Ключи | Что описывает |
+|---|---|---|
+| `osv_headers.yaml` | `sheet_markers`, `statement_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `header_markers` (`account`, `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки |
+| `osv_groups.yaml` | `mapping`, `default` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) |
+| `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end` | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») |
+| `data_types.yaml` | `header`, `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) |
+
+## Как писать паттерны
+
+- Паттерн — регулярное выражение Python. Он ищется (`search`) в **нормализованном** тексте ячейки и применяется
+ без учёта регистра.
+- Нормализация текста (`normalize_text`): нижний регистр, «ё» -> «е», дефисы, тире, неразрывные пробелы и переносы
+ строк -> один пробел, пробелы схлопываются. Пример: `Оборотно-сальдовая\nведомость` -> `оборотно сальдовая ведомость`.
+- Поэтому в паттернах **нет «ё», дефисов, тире и диапазонов `[а-я]`**: пишите `оборотно\s*сальдовая`, `д т` (для
+ «Д-т»), `\w` вместо диапазона. Загрузчик отвергает такой паттерн с указанием файла и ключа.
+- Порядок записей в списках `mapping`, `rules` (периоды), `header`, `row` — приоритет: побеждает первое совпадение,
+ от специфичных правил к общим.
+- В `osv_groups.yaml` известные группировки `OTHER` («Основные средства», «Номенклатура», «Статьи затрат», …)
+ перечислены в `mapping` явно, хотя `default` и так даёт `OTHER`: детектор заголовка таблицы (`src/parser/detect.py`)
+ считает строку под «Дебет/Кредит» частью блока заголовка, только если её текст совпал с записью `mapping`, с `ИНН`
+ или с «субконто». Новая группировка в отдельной строке заголовка = новая строка в `mapping`.
+- Ошибка в словаре (нет обязательного ключа, неизвестный ключ, неверный регэксп или тип) — `RuntimeError` при
+ загрузке с именем файла и ключом.
+
+## Покрытие словарей
+
+Каждая запись распознавания (один `Pattern` в любом из четырёх словарей: `sheet_markers`, `statement_markers`,
+`groups`, `debit`, `credit`, `service_columns`, `total_row`, `header_markers`, `mapping`, правила и `year`/`end`
+периодов, `header`/`row`/`currency_aliases` типов данных) должна сработать хотя бы раз на реальных ОСВ или синтетике:
+это проверяет `tests/fixtures/test_rules_coverage.py` (счётчик совпадений `Pattern`, сообщение — файл, ключ и паттерн
+несработавших записей). Перечень `currency_codes` — список значений ISO 4217, а не синонимы, в покрытие не входит (решение R-22).
+Несработавшая запись — либо мёртвый синоним (удалить из YAML вместе с тестом словаря), либо пробел в фикстурах
+(добавить файл синтетики `s1x_…` с описанием в README fixtures). Так удалены `service_columns.kpp|code|quantity|currency`:
+код читает только `inn`, остальные колонки идут в `extra`. Синтетика `s14`–`s16` закрывает остальные записи, которых нет в
+реальных ОСВ (формы ББ/ОФР по одному признаку, названия групп сумм «Входящее/Начальное/Конечное/Исходящее сальдо»,
+шапка без организации, период «2024», «Детализация: …», группировки `OTHER`).
+
+## Как добавить синоним
+
+1. Найдите словарь и ключ по таблице выше; добавьте строку-паттерн в нужный список (первое совпадение побеждает —
+ ставьте специфичное выше общего). Пример: заголовок «Нач. остаток» вместо «Сальдо на начало» —
+ `groups.opening` в `osv_headers.yaml`: `- 'нач\.\s*остаток'`.
+2. Положите файл с этим форматом в `../fixtures/osv/` (реальный) или добавьте синтетику в `tests/synthetic/generate.py`
+ (с описанием в README fixtures); иначе тест покрытия сочтёт запись мёртвой.
+3. `make golden-update`, просмотрите diff, закоммитьте снимки в репозитории fixtures. Код парсера не меняется.
+
+## Что намеренно не в штатном словаре
+
+Синоним «нач. остаток» / «начальный остаток» для группы `opening` не включён: он нужен, чтобы показать, что новый
+формат подключается строкой в YAML (тест сценария «Новый синоним»).
diff --git a/api/rules/data_types.yaml b/api/rules/data_types.yaml
new file mode 100644
index 0000000..6d49572
--- /dev/null
+++ b/api/rules/data_types.yaml
@@ -0,0 +1,22 @@
+# Типы данных ОСВ (05-domain-rules.md §1.1, §1.3). Паттерны ищутся в нормализованном тексте (см. osv_headers.yaml).
+
+# Значение после «Выводимые данные:» -> data_type шапки; первый совпавший побеждает.
+# ASSUMPTION(OQ-45): НУ принимается как есть, без сверки с БУ.
+header:
+ - ['\bбу\b|бухгалтерск', BU]
+ - ['\bну\b|налогов', NU]
+ - ['\bсумм', SUM]
+
+# Строки под контрагентом: «Вал.» и «Кол.» помечают строку как валютную / количественную
+row:
+ - ['^вал\.?$', CURRENCY]
+ - ['^кол(ичество)?\.?$', QUANTITY]
+
+# Коды валют ISO 4217 (буквенные): строка, целиком равная коду, — валютная; код же — результат `currency_aliases`
+currency_codes: [RUB, USD, EUR, CNY, KZT, BYN, GBP, CHF, JPY, TRY, AED]
+
+# Написания валюты в шапке («Валюта БУ, НУ: руб.» / «RUB») -> код ISO; ключ должен быть в currency_codes
+currency_aliases:
+ RUB:
+ - '\bруб'
+ - '\brub\b'
diff --git a/api/rules/osv_groups.yaml b/api/rules/osv_groups.yaml
new file mode 100644
index 0000000..8691bb6
--- /dev/null
+++ b/api/rules/osv_groups.yaml
@@ -0,0 +1,17 @@
+# Названия группировок ОСВ -> тип группировки `group_type` (05-domain-rules.md §1.1).
+# Паттерны ищутся в нормализованном тексте (см. osv_headers.yaml), первый совпавший побеждает.
+mapping:
+ - ['^(счета?|субконто):?$', ACCOUNT]
+ - ['^подразделени', DEPARTMENT]
+ - ['^контрагент', COUNTERPARTY]
+ - ['^договор', CONTRACT]
+ # Известные группировки OTHER перечислены явно: по ним детектор узнаёт строку-метку в блоке заголовка таблицы
+ # (строка «Основные средства» под «Счет / Подразделение» — часть заголовка, а не первая строка данных)
+ - ['^основн\w*\s+средств', OTHER]
+ - ['^нематериальн\w*\s+актив', OTHER]
+ - ['^номенклатур', OTHER]
+ - ['^стать(и|я)\b', OTHER]
+ - ['^валют', OTHER]
+
+# Всё прочее (не перечисленное выше); такая группировка в отдельной строке блока заголовка не будет узнана как метка
+default: OTHER
diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml
new file mode 100644
index 0000000..bb7acf5
--- /dev/null
+++ b/api/rules/osv_headers.yaml
@@ -0,0 +1,73 @@
+# Словарь заголовков ОСВ: чем помечены лист, блоки сумм, колонки и служебные строки (05-domain-rules.md §1.0–§1.2).
+#
+# Правила записи (проверяет загрузчик):
+# - паттерн — регулярное выражение Python, ищется (`search`) в НОРМАЛИЗОВАННОМ тексте ячейки и применяется без учёта
+# регистра. Нормализация: нижний регистр, «ё» -> «е», дефисы/тире/неразрывные пробелы/переносы строк -> пробел,
+# пробелы схлопываются. Поэтому в паттернах нет «ё», дефисов и диапазонов [а-я]: пишите «оборотно сальдовая
+# ведомость», «д т», `\w` вместо диапазона;
+# - новый синоним = новая строка в нужном списке, без правки кода.
+
+# Лист содержит ОСВ: любая ячейка (первые строки листа) матчит один из паттернов
+sheet_markers:
+ - 'оборотно\s*сальдовая\s+ведомость'
+
+# Листы форм ББ/ОФР (Срез 2b): ОСВ-парсер их пропускает с предупреждением
+statement_markers:
+ - 'бухгалтерский\s+баланс'
+ - '0710001'
+ - 'отчет\s+о\s+финансовых\s+результатах'
+ - '0710002'
+
+# Три группы сумм в заголовке таблицы. «Оборотно сальдовая» (название ведомости) не должно матчить turnover:
+# отсюда границы слова `\bоборот(ы)?\b`.
+groups:
+ opening:
+ - 'сальдо\s+на\s+начало'
+ - '\bнач(альн\w*)?\.?\s*сальдо'
+ - 'входящ\w*\s+сальдо'
+ turnover:
+ - '\bоборот(ы)?\b'
+ closing:
+ - 'сальдо\s+на\s+конец'
+ - '\bкон(ечн\w*)?\.?\s*сальдо'
+ - 'исходящ\w*\s+сальдо'
+
+# Подзаголовки сумм под группой («Дебет»/«Кредит», «Дт»/«Кт», «Д-т»/«К-т»): ячейка начинается с синонима
+debit:
+ - '^(дебет|дт|д т)\b'
+credit:
+ - '^(кредит|кт|к т)\b'
+
+# Служебные колонки таблицы по тексту заголовка. Код знает только колонку ИНН; прочие колонки таблицы (КПП, код,
+# количество, валюта и т. п.) попадают в `rows[].extra` под текстом своего заголовка и словаря не требуют.
+# (Записи kpp/code/quantity/currency удалены: код их не читал, тест покрытия словарей R-22 их не находил.)
+service_columns:
+ inn:
+ - '\bинн\b'
+
+# Итоговая строка таблицы (начало текста первой колонки)
+total_row:
+ - '^итого\b'
+
+# Метки шапки документа (область выше заголовка таблицы); ключи фиксированы
+header_markers:
+ # «по счету 60 за …»: код счёта — сразу после совпадения
+ account:
+ - '\bпо\s+счету\b'
+ # вариант 1: «… за 1 квартал 2025 г.», вариант 2: отдельная строка «Период: 1 квартал 2026 г.»
+ period:
+ - '\bза\s+'
+ - '^период\s*:\s*'
+ # «Выводимые данные: БУ (данные бухгалтерского учета)»
+ data_type:
+ - 'выводимые\s+данные'
+ # «Валюта БУ, НУ: RUB»
+ currency:
+ - '\bвалюта\b'
+ # «Параметры:» — блок над таблицей
+ org_params:
+ - '^параметры\b'
+ # вариант 2: «Детализация по субсчетам, субконто: Контрагенты, Договоры»; в первой колонке заголовка — «Субконто»
+ subkonto:
+ - '\bсубконто\b'
+ - '\bдетализация\b'
diff --git a/api/rules/periods.yaml b/api/rules/periods.yaml
new file mode 100644
index 0000000..0f4e7dc
--- /dev/null
+++ b/api/rules/periods.yaml
@@ -0,0 +1,27 @@
+# Текст периода -> квартал и признак нарастающего итога (05-domain-rules.md §1.5).
+# Порядок важен: первый совпавший паттерн побеждает, правила идут от специфичных к общим («9 месяцев» и
+# «12 месяцев» раньше «год», числовые «\b3\s*мес» с границей слова — не ловят «13 месяцев»).
+# ASSUMPTION(OQ-02): is_cumulative=false для Q2–Q4 определяется по тексту «N квартал»; ядро может переопределить по
+# данным (opening Q3 == opening Q1 -> нарастающая).
+rules:
+ # Кварталы: арабские и римские номера, «1 кв.», «1-й квартал», «I кв.»
+ - {pattern: '\b(1|i)\s*(й|я|ый|ой)?\s*кв', quarter: Q1, is_cumulative: true}
+ - {pattern: '\b(2|ii)\s*(й|я|ый|ой)?\s*кв', quarter: Q2, is_cumulative: false}
+ - {pattern: '\b(3|iii)\s*(й|я|ый|ой)?\s*кв', quarter: Q3, is_cumulative: false}
+ - {pattern: '\b(4|iv)\s*(й|я|ый|ой)?\s*кв', quarter: Q4, is_cumulative: false}
+ # Нарастающие периоды: месяцы, диапазон месяцев, диапазон дат «01.01 - 31.03», «с 01.01.2025 по 31.03.2025»
+ - {pattern: '\b3\s*мес|январ\w*\s+(по\s+)?март|\b01\.01(\.\d{2,4})?(\s*г\.?)?\s+(по\s+)?31\.03', quarter: Q1, is_cumulative: true}
+ - {pattern: 'полугоди|\b6\s*мес|январ\w*\s+(по\s+)?июн|\b01\.01(\.\d{2,4})?(\s*г\.?)?\s+(по\s+)?30\.06', quarter: Q2, is_cumulative: true}
+ - {pattern: '\b9\s*мес|январ\w*\s+(по\s+)?сентябр|\b01\.01(\.\d{2,4})?(\s*г\.?)?\s+(по\s+)?30\.09', quarter: Q3, is_cumulative: true}
+ - {pattern: '\b12\s*мес|январ\w*\s+(по\s+)?декабр|\b01\.01(\.\d{2,4})?(\s*г\.?)?\s+(по\s+)?31\.12|годов|\bгод\b', quarter: Q4, is_cumulative: true}
+ # Год: «за 2024 г.», «2024 год»; год в конце даты («31.03.2025 г.») годом периода не считается
+ - {pattern: '(? «3 квартал 2024 г.»;
+# без совпадения период идёт до конца строки
+end: '\bг\.|\bгода?\b'
diff --git a/api/scripts/golden_update.py b/api/scripts/golden_update.py
new file mode 100644
index 0000000..b66b0e4
--- /dev/null
+++ b/api/scripts/golden_update.py
@@ -0,0 +1,69 @@
+"""Перезапись golden-снимков разбора ОСВ и diff изменившихся (design D-10).
+
+Из каталога `api/`: `ENV_FILE=.env.test .venv/bin/python scripts/golden_update.py [--check]` (то же — `make
+golden-update`, из корня репозитория). Файлы — `fixtures/osv/**` включая `synthetic/`; снимки — в
+`fixtures/golden/expected_osv_parse/` (зеркало `osv/`). Без флага снимки перезаписываются, для изменившихся печатается
+unified diff; `--check` только сравнивает (код возврата 1, если снимки расходятся с разбором). Снимки части, для
+которой нет исходного каталога (`osv/synthetic` отсутствует или пуст, нет реальных файлов), не удаляются.
+
+Снимок — регрессия, а не оракул: перед коммитом в репозиторий fixtures просмотрите diff (`git -C ../fixtures diff`)
+и убедитесь, что каждое изменение — следствие намеренной правки парсера.
+"""
+
+import argparse
+import sys
+from collections.abc import Sequence
+from pathlib import Path
+
+API_ROOT = Path(__file__).resolve().parents[1]
+sys.path[:0] = [str(API_ROOT), str(API_ROOT / "tests")]
+
+from fixtures_helpers import ( # noqa: E402
+ fixtures_skip_reason,
+ osv_fixture_files,
+ resolve_fixtures_dir,
+ synthetic_files,
+)
+from golden_helpers import sync_snapshots # noqa: E402
+
+from src.parser import get_parser # noqa: E402
+
+EXIT_OK = 0
+EXIT_DIFFERS = 1
+EXIT_NO_FIXTURES = 3
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Golden-снимки разбора ОСВ: перезапись + diff, либо --check")
+ parser.add_argument("--check", action="store_true", help="только сравнить снимки с разбором, ничего не писать")
+ options = parser.parse_args(argv)
+
+ reason = fixtures_skip_reason()
+ if reason is not None:
+ print(reason, file=sys.stderr)
+ return EXIT_NO_FIXTURES
+ root = resolve_fixtures_dir()
+ files = [*osv_fixture_files(root), *synthetic_files(root)]
+ report = sync_snapshots(root, files, get_parser(), write=not options.check)
+
+ for diff in report.diffs:
+ sys.stdout.write(diff if diff.endswith("\n") else diff + "\n")
+ mode = "режим --check, ничего не записано" if options.check else "снимки записаны"
+ print(
+ f"файлов: {len(files)}; без изменений: {report.unchanged}; новых: {len(report.created)}; "
+ f"изменено: {len(report.changed)}; лишних снимков: {len(report.orphans)}; сохранено без исходного каталога: "
+ f"{len(report.protected)} ({mode})",
+ file=sys.stderr,
+ )
+ for orphan in report.orphans:
+ print(f"лишний снимок{'' if options.check else ' удалён'}: {orphan}", file=sys.stderr)
+ for kept in report.protected:
+ print(f"снимок сохранён (нет исходного каталога): {kept}", file=sys.stderr)
+ return EXIT_DIFFERS if options.check and report.differs else EXIT_OK
+
+
+if __name__ == "__main__":
+ reconfigure = getattr(sys.stdout, "reconfigure", None)
+ if reconfigure is not None:
+ reconfigure(encoding="utf-8")
+ raise SystemExit(main())
diff --git a/api/src/app.py b/api/src/app.py
index 2956145..434a69c 100644
--- a/api/src/app.py
+++ b/api/src/app.py
@@ -80,7 +80,7 @@ def _build_worker(storage: S3Storage) -> tuple[NatsConnection, RecognitionWorker
worker = RecognitionWorker(
storage=storage,
publisher=nats,
- parser=get_parser(),
+ parser=get_parser(settings),
service_name=settings.SERVICE_NAME,
service_version=VERSION,
concurrency=settings.WORKER_CONCURRENCY,
diff --git a/api/src/contract/__init__.py b/api/src/contract/__init__.py
index 79922fd..bd042b7 100644
--- a/api/src/contract/__init__.py
+++ b/api/src/contract/__init__.py
@@ -1,4 +1,4 @@
-"""Контракт обмена с сервисами распознавания v1.0 (схемы — `api/contracts/`, копии `docs/contracts/`).
+"""Контракт обмена с сервисами распознавания v1.1 (схемы — `api/contracts/`, копии `docs/contracts/`).
Публичный API: pydantic-модели запроса, конверта и файла результата, subject'ы NATS, идентификаторы сообщений.
"""
diff --git a/api/src/contract/common.py b/api/src/contract/common.py
index d0e27a8..8fa3581 100644
--- a/api/src/contract/common.py
+++ b/api/src/contract/common.py
@@ -1,4 +1,4 @@
-"""Общие основы моделей контракта v1.0: базовый класс, версия, типы UUID / деньги / время, метрики и ошибки.
+"""Общие основы моделей контракта v1.1: базовый класс, версия, типы UUID / деньги / время, метрики и ошибки.
Модели повторяют JSON Schema (`api/contracts/`) буквально: схемы не содержат `additionalProperties: false`
(кроме значений `columns` ОСВ), поэтому незнакомые поля принимаются и отбрасываются (`extra="ignore"`) —
@@ -27,7 +27,7 @@ from pydantic import (
model_validator,
)
-CONTRACT_VERSION = "1.0"
+CONTRACT_VERSION = "1.1"
# Шаблоны версий — буквально из схем
SCHEMA_VERSION_PATTERN = r"^1\.\d+$" # свой MAJOR, любой MINOR
diff --git a/api/src/contract/result.py b/api/src/contract/result.py
index 18d752c..4a89716 100644
--- a/api/src/contract/result.py
+++ b/api/src/contract/result.py
@@ -1,4 +1,4 @@
-"""Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v1.0.
+"""Файл результата в S3 (`results/{file_id}/{job_id}/result.json`): `recognition-result.schema.json` v1.1.
tabular-parser формирует этот файл по запросу; один файл → один job → N документов (ОСВ по счёту / ББ / ОФР / прочее).
Модель намеренно совпадает с копией в arm-core (контракт — `../docs/contracts`, обзор —
@@ -102,6 +102,7 @@ class OsvRow(ContractModel):
turnover_credit: Money = None
closing_debit: Money = None
closing_credit: Money = None
+ extra: dict[str, str | None] | None = None # нераспознанные колонки таблицы: заголовок → текст ячейки (§1.2)
class OsvChecks(ContractModel):
diff --git a/api/src/core/config.py b/api/src/core/config.py
index f81a927..25fdb11 100644
--- a/api/src/core/config.py
+++ b/api/src/core/config.py
@@ -48,6 +48,10 @@ class Settings(BaseSettings):
WORKER_CONCURRENCY: PositiveInt = 1 # число заданий, обрабатываемых одновременно
PUBLISH_RETRIES: NonNegativeInt = 3 # повторы публикации ack/результата после первой попытки (0 — без повторов)
DONE_CACHE_SIZE: PositiveInt = 1000 # размер process-local LRU обработанных job_id
+ # Лимиты разбора (design D-8): файл больше лимита — UNSUPPORTED_FORMAT без чтения; превышение времени — TIMEOUT
+ # (кооперативно: между листами, стратегиями и блоками строк). Оба ниже reply_deadline ядра (5 мин)
+ PARSE_MAX_BYTES: PositiveInt = 50 * 1024 * 1024
+ PARSE_TIMEOUT_SECONDS: PositiveInt = 120
model_config = SettingsConfigDict(
extra="ignore",
diff --git a/api/src/core/worker.py b/api/src/core/worker.py
index 83bb91e..997ccf6 100644
--- a/api/src/core/worker.py
+++ b/api/src/core/worker.py
@@ -83,6 +83,23 @@ def result_key(file_id: object, job_id: object) -> str:
return f"results/{file_id}/{job_id}/result.json"
+def build_result(
+ outcome: ParseOutcome, *, job_id: object, file_id: object, service: ServiceInfo, metrics: Metrics | None
+) -> RecognitionResult:
+ """Файл результата контракта из успешного исхода (`done | needs_check`); общий для воркера и CLI разбора файла."""
+ return RecognitionResult.model_validate(
+ {
+ "schema_version": CONTRACT_VERSION,
+ "job_id": job_id,
+ "file_id": file_id,
+ "service": service,
+ "status": ResultStatus(outcome.status),
+ "metrics": metrics,
+ "documents": list(outcome.documents),
+ }
+ )
+
+
class InvalidRequestError(Exception):
"""Запрос не прошёл контракт: `SCHEMA_MISMATCH`."""
@@ -378,16 +395,8 @@ class RecognitionWorker:
key = result_key(request.file_id, request.job_id)
try:
metrics = self._metrics(outcome.metrics, started)
- result = RecognitionResult.model_validate(
- {
- "schema_version": CONTRACT_VERSION,
- "job_id": request.job_id,
- "file_id": request.file_id,
- "service": self._service,
- "status": ResultStatus(outcome.status),
- "metrics": metrics,
- "documents": list(outcome.documents),
- }
+ result = build_result(
+ outcome, job_id=request.job_id, file_id=request.file_id, service=self._service, metrics=metrics
)
await self._storage.put_json(request.s3.bucket, key, result.to_wire())
except Exception as exc:
diff --git a/api/src/parser/__init__.py b/api/src/parser/__init__.py
index eff0a80..68d8c05 100644
--- a/api/src/parser/__init__.py
+++ b/api/src/parser/__init__.py
@@ -1,16 +1,19 @@
"""Интерфейс разбора файла (design D-3): «байты + тип файла → документы / метрики / ошибка».
Парсер не знает о NATS и S3: транспорт (`core/worker.py`) читает файл, вызывает `Parser.parse`, пишет результат.
-Срез 0 — только заглушка `NullParser`; реализация Среза 2 подменяет фабрику `get_parser`, воркер не меняется.
+Реализация по умолчанию — `OsvParser` (`osv.py`, Срез 2a); `NullParser` (заглушка Среза 0) остаётся для тестов воркера.
`parse` — обычный синхронный метод: разбор XLS/XLSX/XLSB CPU-bound, внутри нет await. Воркер (asyncio) вызывает его
через `asyncio.to_thread(parser.parse, data, file_type)`, чтобы не блокировать цикл событий.
"""
from dataclasses import dataclass
+from pathlib import Path
from typing import Protocol, runtime_checkable
from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus, StatementDocument
+from src.core.config import Settings
+from src.parser.rules import DEFAULT_RULES_DIR, load_rules
__all__ = ["NullParser", "ParseOutcome", "Parser", "get_parser"]
@@ -57,6 +60,17 @@ class NullParser:
)
-def get_parser() -> Parser:
- """Фабрика парсера для воркера. Срез 2 заменяет возвращаемую реализацию."""
- return NullParser()
+def get_parser(settings: Settings | None = None, *, rules_dir: Path = DEFAULT_RULES_DIR) -> Parser:
+ """Фабрика парсера для воркера: `OsvParser` со словарями `rules_dir` и лимитами `PARSE_MAX_BYTES` /
+ `PARSE_TIMEOUT_SECONDS` из `settings` (без настроек — значения по умолчанию из `Settings`).
+
+ Словари читаются и валидируются здесь, один раз при старте (design D-7): ошибка словаря — `RuntimeError` с файлом
+ и ключом, сервис не стартует, а не падает на первом файле.
+ """
+ # `osv.py` импортирует `ParseOutcome` из этого пакета: импорт на уровне модуля был бы циклическим
+ from src.parser.osv import OsvParser # noqa: PLC0415 - цикл импорта osv → src.parser
+
+ fields = Settings.model_fields
+ max_bytes = fields["PARSE_MAX_BYTES"].default if settings is None else settings.PARSE_MAX_BYTES
+ timeout_seconds = fields["PARSE_TIMEOUT_SECONDS"].default if settings is None else settings.PARSE_TIMEOUT_SECONDS
+ return OsvParser(load_rules(rules_dir), max_bytes=max_bytes, timeout_seconds=timeout_seconds)
diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py
new file mode 100644
index 0000000..2090570
--- /dev/null
+++ b/api/src/parser/checks.py
@@ -0,0 +1,175 @@
+"""Контрольные суммы документа ОСВ (`05-domain-rules.md` §1.4; spec «Контрольные суммы определяют статус»).
+
+Три проверки над классифицированными строками одного листа (суммы — `Decimal`, `None` = 0, допуск ±0.01):
+
+1. тождество сальдо по каждой строке: `opening_debit − opening_credit + turnover_debit − turnover_credit =
+ closing_debit − closing_credit`;
+2. по каждому родителю: суммы родителя = Σ детей того же `data_type`; строки CURRENCY/QUANTITY («Вал.», «Кол.»)
+ в суммирование не входят и сами родителями не проверяются. Обороты сверяются по колонкам. Сальдо (начало, конец) —
+ по колонкам (развёрнутое сальдо) либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто
+ `дебет − кредит`: 1С показывает счёт свёрнутым сальдо, а субсчета и контрагентов — развёрнутым (так во всех
+ реальных файлах, где у счёта есть субсчета с разными сторонами сальдо);
+3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0); корня нет (пустая ОСВ, R-13) — «Итого»
+ должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна, результат `false`.
+
+Любое нарушение делает результат `needs_check` (решает оркестратор `osv.py`); парсер структуру под сходимость не
+подгоняет. Номера строк — в `OsvChecks`, причины — тексты предупреждений.
+"""
+
+from collections import defaultdict
+from collections.abc import Iterable, Sequence
+from decimal import Decimal
+
+from src.contract.result import OsvChecks
+from src.parser.classify import SUM_FIELDS, ClassifiedRow
+
+__all__ = [
+ "TOLERANCE",
+ "balance_identity",
+ "parent_sums",
+ "run_checks",
+ "total_matches_root",
+]
+
+TOLERANCE = Decimal("0.01")
+# Сколько номеров строк показывать в тексте предупреждения; полный список — в `checks`
+WARNING_ROWS_SHOWN = 20
+_ZERO = Decimal(0)
+# Строки-показатели: суммы в других единицах (валюта, количество), в рублёвые суммы родителя не входят
+_INDICATOR_DATA_TYPES = frozenset({"CURRENCY", "QUANTITY"})
+_ROOT_LEVEL = 0
+# Позиции сумм в `BodyRow.sums` (порядок `classify.SUM_FIELDS`): дебет и кредит каждой группы рядом
+_OPENING, _TURNOVER, _CLOSING = slice(0, 2), slice(2, 4), slice(4, 6)
+
+TOTAL_NOT_FOUND_WARNING = "строка «Итого» не найдена"
+
+
+def _values(item: ClassifiedRow) -> tuple[Decimal, ...]:
+ return tuple(_ZERO if value is None else value for value in item.body.sums)
+
+
+def _net(pair: Sequence[Decimal]) -> Decimal:
+ debit, credit = pair
+ return debit - credit
+
+
+def _differs(left: Sequence[Decimal], right: Sequence[Decimal]) -> bool:
+ return any(abs(a - b) > TOLERANCE for a, b in zip(left, right, strict=True))
+
+
+def _is_indicator(item: ClassifiedRow) -> bool:
+ return item.data_type in _INDICATOR_DATA_TYPES
+
+
+def balance_identity(rows: Sequence[ClassifiedRow]) -> list[int]:
+ """Номера строк, где начало + обороты ≠ конец (±0.01, пусто = 0); проверяются все строки, включая «Итого»."""
+ violations: list[int] = []
+ for item in rows:
+ sums = _values(item)
+ expected = _net(sums[_OPENING]) + _net(sums[_TURNOVER])
+ if abs(expected - _net(sums[_CLOSING])) > TOLERANCE:
+ violations.append(item.body.row)
+ return violations
+
+
+def _balance_matches(parent: Sequence[Decimal], children: Sequence[Decimal]) -> bool:
+ """Пара «дебет, кредит» сальдо: равна по колонкам или, при свёрнутом сальдо родителя, по нетто."""
+ if not _differs(parent, children):
+ return True
+ is_collapsed = any(abs(value) <= TOLERANCE for value in parent)
+ return is_collapsed and abs(_net(parent) - _net(children)) <= TOLERANCE
+
+
+def _sums_match(parent: Sequence[Decimal], children: Sequence[Decimal]) -> bool:
+ return (
+ _balance_matches(parent[_OPENING], children[_OPENING])
+ and not _differs(parent[_TURNOVER], children[_TURNOVER])
+ and _balance_matches(parent[_CLOSING], children[_CLOSING])
+ )
+
+
+def parent_sums(rows: Sequence[ClassifiedRow]) -> list[int]:
+ """Номера родителей, чьи суммы ≠ Σ детей того же `data_type` (±0.01): обороты — по колонкам, сальдо — по колонкам
+ или по нетто при свёрнутом сальдо родителя (см. описание модуля).
+
+ Дерево — по `parent` (номерам строк), не по текстам: одинаковые контрагенты под разными субсчетами не смешиваются.
+ Строки CURRENCY/QUANTITY не суммируются и не проверяются как родители; родитель без учитываемых детей не
+ проверяется.
+ """
+ by_row = {item.body.row: item for item in rows}
+ totals: dict[int, list[Decimal]] = defaultdict(lambda: [_ZERO] * len(SUM_FIELDS))
+ for item in rows:
+ if item.parent is None or _is_indicator(item):
+ continue
+ parent = by_row.get(item.parent)
+ if parent is None or _is_indicator(parent) or parent.data_type != item.data_type:
+ continue
+ accumulated = totals[item.parent]
+ for index, value in enumerate(_values(item)):
+ accumulated[index] += value
+ return [row for row in by_row if row in totals and not _sums_match(_values(by_row[row]), totals[row])]
+
+
+def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
+ return next((item for item in reversed(rows) if item.group_type == "TOTAL"), None)
+
+
+def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None:
+ return next(
+ (item for item in rows if item.group_type == "ACCOUNT" and item.level == _ROOT_LEVEL and item.parent is None),
+ None,
+ )
+
+
+def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool:
+ """«Итого» = первая корневая строка ACCOUNT по шести колонкам (±0.01).
+
+ Нет «Итого» — `False` (R-20); нет корня — `True`, только если все суммы «Итого» пусты или нулевые (R-13).
+ """
+ total = _total(rows)
+ if total is None:
+ return False
+ root = _root(rows)
+ if root is None:
+ return all(abs(value) <= TOLERANCE for value in _values(total))
+ return not _differs(_values(total), _values(root))
+
+
+def _rows_text(rows: Iterable[int]) -> str:
+ numbers = list(rows)
+ shown = ", ".join(str(number) for number in numbers[:WARNING_ROWS_SHOWN])
+ if len(numbers) > WARNING_ROWS_SHOWN:
+ return f"{shown} … (всего {len(numbers)})"
+ return shown
+
+
+def _total_warning(rows: Sequence[ClassifiedRow]) -> str:
+ total = _total(rows)
+ root = _root(rows)
+ if total is None:
+ return TOTAL_NOT_FOUND_WARNING
+ if root is None:
+ return f"§1.4: строка «Итого» (строка {total.body.row}) не пуста, а корневого счёта нет"
+ return f"§1.4: строка «Итого» (строка {total.body.row}) ≠ корневому счёту (строка {root.body.row})"
+
+
+def run_checks(rows: Sequence[ClassifiedRow]) -> tuple[OsvChecks, list[str]]:
+ """Три проверки §1.4 и предупреждения с номерами строк (пустой список — всё сошлось)."""
+ identity = balance_identity(rows)
+ parents = parent_sums(rows)
+ matches = total_matches_root(rows)
+ warnings: list[str] = []
+ if identity:
+ warnings.append(
+ f"§1.4: сальдо на начало + обороты ≠ сальдо на конец (±{TOLERANCE}): строки {_rows_text(identity)}"
+ )
+ if parents:
+ warnings.append(f"§1.4: суммы родителя ≠ сумме детей (±{TOLERANCE}): строки {_rows_text(parents)}")
+ if not matches:
+ warnings.append(_total_warning(rows))
+ checks = OsvChecks(
+ total_row_matches_root=matches,
+ balance_identity_violations=identity,
+ parent_sum_violations=parents,
+ )
+ return checks, warnings
diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py
new file mode 100644
index 0000000..3cb56ea
--- /dev/null
+++ b/api/src/parser/classify.py
@@ -0,0 +1,340 @@
+"""Строки табличной части ОСВ и их классификация (spec «Классификация строк», design D-4; `05-domain-rules.md`
+§1.2–§1.4).
+
+Два шага вокруг каскада иерархии (`hierarchy.resolve_levels`):
+
+1. `collect_body_rows` — строки таблицы от первой строки данных до строки «Итого» включительно: текст строки, признак
+ счёта, оформление и шесть сумм (по `ColumnMap`; свёрнутое сальдо раскладывается по знаку, ruling R-1). Строка
+ «Итого» выбирается здесь, по тексту и положению, до уровней: каскаду она нужна, чтобы закрепить её на уровне 0.
+2. `classify_rows` — тип группировки строки по позиции в `group_schema`, счёт ветки, контрагент и ИНН, `data_type`,
+ нераспознанные колонки таблицы (`extra`).
+
+Позиции строк и колонок берутся только из `TableHeader` и содержимого ячеек; единственная регулярка в коде —
+`ACCOUNT_RE`: формат кода счёта — структура, а не синоним (цифры только ASCII, название после запятой — в том числе
+многострочное, M-1). Буквенные сегменты субсчёта (R-16) оставляют известный
+остаточный ложноположительный случай: текст вида «12.ООО» (2–3 цифры, точка, до трёх прописных букв) — счёт.
+"""
+
+import re
+from collections.abc import Sequence
+from dataclasses import dataclass
+from decimal import Decimal
+from typing import Literal
+
+from src.contract.common import DataType
+from src.contract.result import OsvGroup
+from src.parser.deadline import Deadline
+from src.parser.detect import GROUP_ORDER, ColumnMap, TableHeader, first_data_row, is_header_repeat
+from src.parser.grid import Sheet, column_letter
+from src.parser.hierarchy import BodyRow, Levels
+from src.parser.numbers import inn_digits, to_money
+from src.parser.rules import GroupType, Rules, first_match, normalize_text
+
+__all__ = [
+ "ACCOUNT_RE",
+ "DEADLINE_CHECK_EVERY_ROWS",
+ "SUM_FIELDS",
+ "ClassifiedRow",
+ "RowGroupType",
+ "classify_rows",
+ "collect_body_rows",
+]
+
+# Код счёта: две-три цифры и до двух сегментов субсчёта («60», «60.01», «76.07.1», «001»); сегмент — 1–2 цифры или
+# 1–3 прописные буквы кириллицы/латиницы (буквенные субсчета 1С «76.АВ», «76.ВА», «76.ОТ», R-16; регистр учитывается:
+# «76.ав» — не счёт). После запятой — название («60.01, Расчеты с поставщиками»; пробел после запятой не обязателен,
+# R-18). Группа 1 — код как в файле (`account_code_raw`), группа 2 — название.
+# Флаги (M-1): re.ASCII — `\d` только 0–9 (арабско-индийские «٦٠» и полноширинные цифры — не код счёта; явные диапазоны
+# `[А-ЯЁA-Z]` флаг не трогает), re.DOTALL — название после запятой может занимать несколько строк ячейки
+# («60.01, Расчеты\nс поставщиками» — счёт, а не контрагент, который сдвинул бы group_type детей)
+ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL)
+_SEGMENT_SEPARATOR = "."
+# Кооперативный дедлайн (design D-8): проверка каждые N строк листа
+DEADLINE_CHECK_EVERY_ROWS = 500
+# Шесть сумм строки в порядке полей `ColumnMap` и `OsvRow` контракта
+SUM_FIELDS = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit")
+# Позиция группировки в `group_schema`: индекс 0 — ACCOUNT, первая не-счётная группировка — индекс 1
+_ACCOUNT_POSITION = 0
+
+RowGroupType = GroupType | Literal["TOTAL"]
+
+
+@dataclass(frozen=True)
+class ClassifiedRow:
+ """Строка `rows[]` контракта до сборки `OsvRow`: исходная строка, место в дереве и классификация."""
+
+ body: BodyRow
+ level: int
+ parent: int | None
+ group_type: RowGroupType
+ group_name: str | None
+ account_code_raw: str | None # код ближайшего счёта ветки, как в файле
+ counterparty_name: str | None
+ counterparty_inn: str | None
+ data_type: DataType | None
+ extra: dict[str, str | None] | None
+
+
+# --- Строки таблицы ---
+
+
+def _in_table(sheet: Sheet, row: int, header: TableHeader) -> bool:
+ """В строке есть значение в пределах таблицы; строка лишь с ячейками правее правого края — не строка таблицы."""
+ return any(col <= header.right_edge for col, _ in sheet.row_cells(row))
+
+
+def _table_rows(sheet: Sheet, header: TableHeader, rules: Rules, deadline: Deadline) -> list[int]:
+ """Непустые строки после блока заголовка, кроме повторов заголовка и строк вне таблицы (Review Focus 4, 5).
+
+ Строка из одних пробелов пустая: `Sheet` не хранит такие ячейки в строках, а повтор заголовка — не данные.
+ """
+ found: list[int] = []
+ for index, row in enumerate(range(first_data_row(sheet, header), sheet.n_rows + 1), start=1):
+ if index % DEADLINE_CHECK_EVERY_ROWS == 0:
+ deadline.check("строки таблицы")
+ if sheet.is_row_empty(row) or not _in_table(sheet, row, header):
+ continue
+ if is_header_repeat(sheet, row, header, rules):
+ continue
+ found.append(row)
+ return found
+
+
+def _text_cell_style(sheet: Sheet, row: int, col: int) -> tuple[bool, int]:
+ cell = sheet.cell(*sheet.merged_origin(row, col))
+ return cell.bold, cell.indent
+
+
+def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: Rules) -> int | None:
+ """Строка «Итого»: последняя строка таблицы, чей текст матчит `total_row`, на корневом уровне оформления.
+
+ Суммы не требуются (R-13: пустая ОСВ — «Итого» без сумм). Выбор — до уровней (их каскад считает с уже известной
+ строкой «Итого»), поэтому «корневой уровень» проверяется по оформлению: outline level и отступ строки не больше,
+ чем у любой другой строки таблицы. Так контрагент «Итого-Сервис» в середине данных не TOTAL (за ним есть настоящее
+ «Итого»), а последний «Итого-Сервис» под счётом с outline/отступом — тоже не TOTAL (T3.3).
+ Известный риск: на листе без outline и отступов последняя строка-контрагент «Итого-…» при отсутствии настоящего
+ «Итого» станет TOTAL; это ловит проверка «TOTAL = корень» (§1.4).
+ """
+ if not rows:
+ return None
+ outline_floor = min(sheet.outline(row) for row in rows)
+ indent_floor = min(_text_cell_style(sheet, row, header.text_col)[1] for row in rows)
+ for row in reversed(rows):
+ text = sheet.text_at(row, header.text_col)
+ if not any(pattern.search(text) for pattern in rules.headers.total_row):
+ continue
+ _, indent = _text_cell_style(sheet, row, header.text_col)
+ is_root = sheet.outline(row) == outline_floor and indent == indent_floor
+ return row if is_root else None
+ return None
+
+
+def _split_collapsed(sums: dict[str, Decimal | None], columns: ColumnMap) -> None:
+ """Свёрнутое сальдо (одна колонка, значение в `*_debit`): отрицательное — в `*_credit` по модулю (R-1)."""
+ for group in columns.collapsed:
+ debit, credit = f"{group}_debit", f"{group}_credit"
+ value = sums[debit]
+ if value is not None and value < 0:
+ sums[debit], sums[credit] = None, -value
+
+
+def _sums(sheet: Sheet, row: int, columns: ColumnMap, warnings: list[str]) -> tuple[Decimal | None, ...]:
+ """Шесть сумм в порядке `SUM_FIELDS`; нечисловое значение — `None` и предупреждение с номером строки."""
+ sums: dict[str, Decimal | None] = dict.fromkeys(SUM_FIELDS)
+ for name, col in columns.indexes().items():
+ parsed = to_money(sheet.cell(row, col))
+ if parsed.warning is not None:
+ warnings.append(f"строка {row}: {parsed.warning}")
+ sums[name] = parsed.value
+ _split_collapsed(sums, columns)
+ return tuple(sums.values())
+
+
+def _body_row(sheet: Sheet, row: int, header: TableHeader, warnings: list[str]) -> BodyRow:
+ text = sheet.text_at(row, header.text_col)
+ account = ACCOUNT_RE.match(text)
+ bold, indent = _text_cell_style(sheet, row, header.text_col)
+ return BodyRow(
+ row=row,
+ text=text,
+ is_account=account is not None,
+ account_rank=None if account is None else account.group(1).count(_SEGMENT_SEPARATOR) + 1,
+ bold=bold,
+ indent=indent,
+ outline=sheet.outline(row),
+ sums=_sums(sheet, row, header.columns, warnings),
+ )
+
+
+def _collapsed_warning(columns: ColumnMap) -> str:
+ groups = ", ".join(group for group in GROUP_ORDER if group in columns.collapsed)
+ return (
+ f"свёрнутое сальдо: {groups} — одна колонка без «Дебет/Кредит»: положительное значение отнесено в дебет, "
+ "отрицательное (по модулю) — в кредит"
+ )
+
+
+def collect_body_rows(
+ sheet: Sheet, header: TableHeader, rules: Rules, deadline: Deadline
+) -> tuple[list[BodyRow], int | None, list[str]]:
+ """Строки данных листа (включая «Итого»), номер строки «Итого» (`None` — не найдена) и предупреждения.
+
+ Строки после «Итого» — подвал (подписи, числа правее таблицы): ни в данные, ни в `extra` они не попадают (T4.5).
+ """
+ warnings: list[str] = []
+ if header.columns.collapsed:
+ warnings.append(_collapsed_warning(header.columns))
+ rows = _table_rows(sheet, header, rules, deadline)
+ total_row = _find_total(sheet, header, rows, rules)
+ if total_row is not None:
+ rows = rows[: rows.index(total_row) + 1]
+ body: list[BodyRow] = []
+ for index, row in enumerate(rows, start=1):
+ if index % DEADLINE_CHECK_EVERY_ROWS == 0:
+ deadline.check("суммы строк")
+ body.append(_body_row(sheet, row, header, warnings))
+ return body, total_row, warnings
+
+
+# --- Классификация ---
+
+
+def _extra_keys(sheet: Sheet, header: TableHeader, rows: Sequence[BodyRow]) -> dict[int, str]:
+ """Колонки `extra` со значениями в строках таблицы и их ключи без повторов.
+
+ `header.extra_cols` собран по всем строкам листа, поэтому колонки, заполненные только в подвале, отбрасываются.
+ Одинаковые заголовки двух колонок различаются буквой колонки в скобках: «Кол-во (B)», «Кол-во (C)»
+ (Review Focus 1); заголовок без повторов — как в файле.
+ """
+ used = {
+ col: title for col, title in header.extra_cols.items() if any(sheet.text_at(item.row, col) for item in rows)
+ }
+ titles = list(used.values())
+ return {col: f"{title} ({column_letter(col)})" if titles.count(title) > 1 else title for col, title in used.items()}
+
+
+def _extra(sheet: Sheet, row: int, keys: dict[int, str]) -> dict[str, str | None] | None:
+ values = {key: sheet.text_at(row, col) or None for col, key in keys.items()}
+ return values if any(value is not None for value in values.values()) else None
+
+
+def _row_data_type(text: str, rules: Rules, currency_codes: frozenset[str]) -> DataType | None:
+ """CURRENCY/QUANTITY по словарю строк («Вал.», «Кол.») или коду валюты ISO («USD», «usd»); иначе `None`."""
+ found = first_match(rules.data_types.row, text)
+ if found is not None:
+ return found
+ return "CURRENCY" if normalize_text(text).upper() in currency_codes else None
+
+
+@dataclass(frozen=True)
+class _Branch:
+ """Контекст строки для её детей: счёт ветки и число не-счётных группировок после последнего счёта."""
+
+ account_code_raw: str | None
+ depth: int
+
+
+_ROOT_BRANCH = _Branch(None, 0)
+
+
+@dataclass(frozen=True)
+class _Kind:
+ """Классификация одной строки (без места в дереве и `extra`)."""
+
+ group_type: RowGroupType
+ group_name: str | None
+ account_code_raw: str | None
+ data_type: DataType | None
+ counterparty_name: str | None = None
+ counterparty_inn: str | None = None
+
+
+@dataclass(frozen=True)
+class _Context:
+ schema: Sequence[OsvGroup]
+ header: TableHeader
+ sheet: Sheet
+ doc_data_type: DataType | None
+ rules: Rules
+ currency_codes: frozenset[str]
+
+
+def _schema_group(schema: Sequence[OsvGroup], position: int) -> tuple[GroupType, str | None]:
+ if position >= len(schema):
+ return "OTHER", None
+ return schema[position].type, schema[position].name
+
+
+def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple[_Kind, _Branch]:
+ """Тип строки (кроме TOTAL) по признаку счёта, словарю строк-показателей и позиции в схеме; контекст для детей."""
+ # ASSUMPTION(OQ-45): `data_type` шапки (в том числе НУ) — как есть
+ doc_data_type = context.doc_data_type
+ account = ACCOUNT_RE.match(item.text) if item.is_account else None
+ if account is not None:
+ code = account.group(1)
+ _, name = _schema_group(context.schema, _ACCOUNT_POSITION)
+ return _Kind("ACCOUNT", name, code, doc_data_type), _Branch(code, 0)
+ code = inherited.account_code_raw
+ row_data_type = _row_data_type(item.text, context.rules, context.currency_codes)
+ if row_data_type is not None:
+ return _Kind("OTHER", None, code, row_data_type), inherited
+ position = inherited.depth + 1
+ group_type, name = _schema_group(context.schema, position)
+ branch = _Branch(code, position)
+ if group_type != "COUNTERPARTY":
+ return _Kind(group_type, name, code, doc_data_type), branch
+ inn_col = context.header.inn_col
+ inn = None if inn_col is None else inn_digits(context.sheet.cell(item.row, inn_col))
+ return _Kind(group_type, name, code, doc_data_type, counterparty_name=item.text, counterparty_inn=inn), branch
+
+
+def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно
+ rows: Sequence[BodyRow],
+ levels: Levels,
+ schema: list[OsvGroup],
+ header: TableHeader,
+ sheet: Sheet,
+ doc_data_type: DataType | None,
+ total_row: int | None,
+ rules: Rules,
+) -> list[ClassifiedRow]:
+ """Классификация строк по дереву `levels` и схеме группировок.
+
+ - «Итого» (`total_row`) — TOTAL, уровень 0 без родителя; в `rows` остаётся (# ASSUMPTION(OQ-01): хранить ли
+ «Итого», решает ядро);
+ - счёт (`ACCOUNT_RE`) — ACCOUNT, `account_code_raw` — код как в файле; дети наследуют код ветки;
+ - строка-показатель («Вал.», «Кол.», код валюты) — `data_type` CURRENCY/QUANTITY, группа OTHER: это не уровень
+ группировки, а вид сумм родителя; позицию в схеме она не занимает;
+ - прочая строка — `schema[позиция]`, позиция = число не-счётных предков после последнего счёта + 1; за схемой —
+ OTHER. COUNTERPARTY получает `counterparty_name` (текст строки) и `counterparty_inn` (колонка ИНН).
+ """
+ context = _Context(schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes))
+ extra_keys = _extra_keys(sheet, header, rows)
+ branches: dict[int, _Branch] = {}
+ result: list[ClassifiedRow] = []
+ for item in rows:
+ if item.row == total_row:
+ # ASSUMPTION(OQ-01): строка «Итого» остаётся в rows; хранить ли её, решает ядро
+ kind, branch = _Kind("TOTAL", None, None, doc_data_type), _ROOT_BRANCH
+ level, parent = 0, None
+ else:
+ parent = levels.parent[item.row]
+ inherited = _ROOT_BRANCH if parent is None else branches.get(parent, _ROOT_BRANCH)
+ kind, branch = _classify_row(item, inherited, context)
+ level = levels.by_row[item.row]
+ branches[item.row] = branch
+ result.append(
+ ClassifiedRow(
+ body=item,
+ level=level,
+ parent=parent,
+ group_type=kind.group_type,
+ group_name=kind.group_name,
+ account_code_raw=kind.account_code_raw,
+ counterparty_name=kind.counterparty_name,
+ counterparty_inn=kind.counterparty_inn,
+ data_type=kind.data_type,
+ extra=_extra(sheet, item.row, extra_keys),
+ )
+ )
+ return result
diff --git a/api/src/parser/cli.py b/api/src/parser/cli.py
new file mode 100644
index 0000000..60a061d
--- /dev/null
+++ b/api/src/parser/cli.py
@@ -0,0 +1,133 @@
+"""Разбор одного файла из командной строки: `python -m src.parser.cli <файл> [--file-type XLSX|XLS|XLSB]` (из `api/`).
+
+В stdout — JSON файла результата контракта v1.1 (тот же, что воркер кладёт в S3 под `results/…/result.json`; сборка
+— `core.worker.build_result`) для `done | needs_check`, либо `{"status": "error", "error": {...}}` для ошибки разбора.
+JSON детерминирован по форме: `ensure_ascii=False`, `indent=2`, порядок полей — порядок модели (`sort_keys` нет);
+суммы — строки. `job_id`/`file_id` — фиксированные нулевые заглушки, `service` — `tabular-parser` с версией по
+умолчанию. Метрика `duration_ms` — фактическая, поэтому от запуска к запуску меняется.
+
+Тип файла по умолчанию определяется по расширению (`.xls`, `.xlsx`, `.xlsb`, регистр не важен); `--file-type` его
+переопределяет. Код возврата:
+
+- 0 — `done`;
+- 1 — `needs_check` (нарушены контрольные суммы §1.4);
+- 2 — `error` (результат разбора — ошибка; код и сообщение — в stdout и в stderr);
+- 3 — файл не найден / не читается / ошибка использования (нет аргумента, неизвестное расширение без `--file-type`,
+ недопустимое значение `--file-type`): у argparse код 2 по умолчанию, но он занят статусом `error`, поэтому
+ ошибки использования переопределены на 3.
+"""
+
+import argparse
+import json
+import sys
+import uuid
+from collections.abc import Sequence
+from pathlib import Path
+from typing import Any, NoReturn
+
+from src.contract import FileType, ResultStatus, ServiceInfo
+from src.core.config import Settings
+from src.core.worker import build_result
+from src.parser import ParseOutcome, get_parser
+
+__all__ = ["main"]
+
+EXIT_DONE = 0
+EXIT_NEEDS_CHECK = 1
+EXIT_ERROR = 2
+EXIT_USAGE = 3
+
+STUB_ID = uuid.UUID(int=0)
+FILE_TYPES: tuple[FileType, ...] = ("XLS", "XLSX", "XLSB")
+JSON_INDENT = 2
+
+
+class _Parser(argparse.ArgumentParser):
+ """Ошибки использования — код 3 (код 2 занят статусом `error`)."""
+
+ def error(self, message: str) -> NoReturn:
+ self.print_usage(sys.stderr)
+ self.exit(EXIT_USAGE, f"{self.prog}: ошибка: {message}\n")
+
+
+def _build_argument_parser() -> argparse.ArgumentParser:
+ parser = _Parser(
+ prog="python -m src.parser.cli",
+ description="Разбор XLS/XLSX/XLSB (ОСВ): JSON файла результата — в stdout.",
+ epilog="Коды возврата: 0 — done, 1 — needs_check, 2 — error, 3 — файл не найден / ошибка использования.",
+ )
+ parser.add_argument("path", type=Path, metavar="файл", help="путь к файлу XLS/XLSX/XLSB")
+ parser.add_argument(
+ "--file-type",
+ choices=FILE_TYPES,
+ help="тип файла; по умолчанию — по расширению файла",
+ )
+ return parser
+
+
+def _file_type(path: Path, explicit: FileType | None) -> FileType | None:
+ if explicit is not None:
+ return explicit
+ extension = path.suffix.lstrip(".").upper()
+ return next((file_type for file_type in FILE_TYPES if file_type == extension), None)
+
+
+def _error_payload(outcome: ParseOutcome) -> dict[str, Any]:
+ payload: dict[str, Any] = {"status": ResultStatus.ERROR.value}
+ if outcome.error is not None:
+ payload["error"] = outcome.error.model_dump(mode="json", exclude_unset=True)
+ if outcome.metrics is not None:
+ payload["metrics"] = outcome.metrics.model_dump(mode="json", exclude_unset=True)
+ return payload
+
+
+def _dump(payload: dict[str, Any]) -> str:
+ return json.dumps(payload, ensure_ascii=False, indent=JSON_INDENT) + "\n"
+
+
+def _parse_arguments(argv: Sequence[str] | None) -> argparse.Namespace | int:
+ try:
+ return _build_argument_parser().parse_args(argv)
+ except SystemExit as exit_request: # --help (0) и ошибки использования (3)
+ return exit_request.code if isinstance(exit_request.code, int) else EXIT_USAGE
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """Разбирает файл и печатает JSON; возвращает код возврата процесса."""
+ arguments = _parse_arguments(argv)
+ if isinstance(arguments, int):
+ return arguments
+ path: Path = arguments.path
+ file_type = _file_type(path, arguments.file_type)
+ if file_type is None:
+ print(
+ f"неизвестное расширение файла {path.suffix!r}: укажите тип явно, --file-type {{{','.join(FILE_TYPES)}}}",
+ file=sys.stderr,
+ )
+ return EXIT_USAGE
+ try:
+ data = path.read_bytes()
+ except OSError as exc:
+ print(f"файл не прочитан: {path}: {exc.strerror or type(exc).__name__}", file=sys.stderr)
+ return EXIT_USAGE
+
+ outcome = get_parser().parse(data, file_type)
+ if outcome.status is ResultStatus.ERROR:
+ error = outcome.error
+ print(f"{error.code.value}: {error.message}" if error else "ошибка разбора", file=sys.stderr)
+ sys.stdout.write(_dump(_error_payload(outcome)))
+ return EXIT_ERROR
+
+ service = ServiceInfo.model_validate(
+ {"name": Settings.model_fields["SERVICE_NAME"].default, "version": Settings.model_fields["VERSION"].default}
+ )
+ result = build_result(outcome, job_id=STUB_ID, file_id=STUB_ID, service=service, metrics=outcome.metrics)
+ sys.stdout.write(_dump(result.to_wire()))
+ return EXIT_DONE if outcome.status is ResultStatus.DONE else EXIT_NEEDS_CHECK
+
+
+if __name__ == "__main__":
+ reconfigure = getattr(sys.stdout, "reconfigure", None)
+ if reconfigure is not None:
+ reconfigure(encoding="utf-8") # вывод не зависит от локали терминала
+ raise SystemExit(main())
diff --git a/api/src/parser/deadline.py b/api/src/parser/deadline.py
new file mode 100644
index 0000000..e72ccdc
--- /dev/null
+++ b/api/src/parser/deadline.py
@@ -0,0 +1,26 @@
+"""Кооперативный таймаут разбора (design D-8, ruling R-3).
+
+Разбор идёт в `asyncio.to_thread`, поток нельзя прервать снаружи, поэтому шаги парсера сами вызывают
+`Deadline.check(<шаг>)` между этапами; превышение — `ParseTimeoutError` (код контракта `TIMEOUT`).
+"""
+
+import time
+from collections.abc import Callable
+
+from src.parser.errors import ParseTimeoutError
+
+__all__ = ["Deadline"]
+
+
+class Deadline:
+ """Лимит времени, отсчитываемый от создания; часы подменяются в тестах."""
+
+ def __init__(self, seconds: float, clock: Callable[[], float] = time.monotonic) -> None:
+ self._seconds = seconds
+ self._clock = clock
+ self._expires_at = clock() + seconds
+
+ def check(self, where: str) -> None:
+ """Бросает `ParseTimeoutError`, если лимит превышен; `where` — имя шага для сообщения."""
+ if self._clock() > self._expires_at:
+ raise ParseTimeoutError(f"превышен лимит {self._seconds} с на шаге {where}")
diff --git a/api/src/parser/detect.py b/api/src/parser/detect.py
new file mode 100644
index 0000000..64f9ce4
--- /dev/null
+++ b/api/src/parser/detect.py
@@ -0,0 +1,492 @@
+"""Детекторы структуры листа по содержимому (design D-2; `05-domain-rules.md` §1.0 п. 1–4, §1.2).
+
+Чистые функции над `Sheet`: вид листа (ОСВ / форма ББ-ОФР / прочее), блок заголовка таблицы, шесть колонок сумм,
+колонки текста строки и ИНН, дополнительные колонки, повтор заголовка внутри данных. Результат — индексы колонок и
+номера строк, вычисленные из текста ячеек; ни одна позиция не задана заранее. Буквы колонок появляются только в
+диагностике (`ColumnMap.as_diagnostics` через `grid.column_letter`). Синонимы — в `rules/*.yaml`.
+"""
+
+import re
+from collections.abc import Iterable, Iterator
+from dataclasses import dataclass, fields
+from decimal import Decimal
+from enum import StrEnum
+from functools import lru_cache
+from typing import Literal
+
+from src.parser.deadline import Deadline
+from src.parser.errors import InvalidFormatError
+from src.parser.grid import Cell, Sheet, column_letter
+from src.parser.rules import Pattern, Rules, SumGroup, normalize_text
+
+__all__ = [
+ "DEADLINE_CHECK_EVERY_ROWS",
+ "INN_LENGTHS",
+ "ColumnMap",
+ "SheetKind",
+ "TableHeader",
+ "classify_sheet",
+ "find_table_header",
+ "first_data_row",
+ "inn_from_cell",
+ "is_header_repeat",
+]
+
+CollapsedGroup = Literal["opening", "closing"]
+Side = Literal["debit", "credit"]
+
+# Признак ОСВ / формы ищется в начале листа: шапка ведомости всегда над таблицей
+SHEET_MARKER_ROWS = 30
+# Блок заголовка таблицы — от 1 до 4 строк: строка групп и до трёх строк под ней (подзаголовки, метки группировок)
+HEADER_MAX_ROWS = 4
+# Сколько строк данных смотреть, чтобы найти колонку текста строки и колонку ИНН по значениям
+TEXT_COL_SAMPLE_ROWS = 20
+INN_SAMPLE_ROWS = 200
+# Колонка без заголовка «ИНН» считается колонкой ИНН, если в ней не меньше 3 непустых значений и не меньше 80 % из
+# них — ИНН (10 или 12 цифр): одно случайное десятизначное число на всю колонку — не признак
+INN_SHARE_PERCENT = 80
+PERCENT = 100
+INN_MIN_VALUES = 3
+INN_LENGTHS = frozenset({10, 12})
+# Строка групп сумм обязана содержать «обороты» и «сальдо на конец»; «сальдо на начало» может отсутствовать
+# (spec «Допустимы: отсутствие группы «начало»», ruling R-12)
+REQUIRED_GROUPS: frozenset[SumGroup] = frozenset({"turnover", "closing"})
+# Повтор заголовка внутри данных: строка, где встретились хотя бы две группы сумм
+MIN_GROUPS = 2
+# Знаки между названием группы и стороной в заголовке в одну строку: «Сальдо на начало, Дт», «Обороты: Кт», «(Дт)»
+_LEADING_PUNCTUATION = re.compile(r"^[\W_]+")
+# Кооперативный дедлайн (design D-8, R-23): проверка каждые N строк в циклах по всему листу
+DEADLINE_CHECK_EVERY_ROWS = 500
+# Кэш нормализованного текста ячеек: каждая ячейка нормализуется один раз на все паттерны
+_NORMALIZE_CACHE_SIZE = 16_384
+
+GROUP_ORDER: tuple[SumGroup, ...] = ("opening", "turnover", "closing")
+# Названия групп в сообщениях об ошибках (spec: INVALID_FORMAT «с названием ненайденной группы»)
+GROUP_TITLES: dict[SumGroup, str] = {
+ "opening": "сальдо на начало",
+ "turnover": "обороты",
+ "closing": "сальдо на конец",
+}
+SIDE_TITLES: dict[Side, str] = {"debit": "Дебет", "credit": "Кредит"}
+
+
+class SheetKind(StrEnum):
+ OSV = "osv"
+ STATEMENT = "statement"
+ OTHER = "other"
+
+
+@dataclass(frozen=True)
+class ColumnMap:
+ """Шесть колонок сумм (1-based индексы; `None` — группы нет).
+
+ Свёрнутое сальдо (одна колонка в группе без «Дебет/Кредит») кладётся в `*_debit`, `*_credit = None`, группа
+ попадает в `collapsed`; раскладку значения по знаку делает классификатор строк (ruling R-1).
+ """
+
+ opening_debit: int | None
+ opening_credit: int | None
+ turnover_debit: int | None
+ turnover_credit: int | None
+ closing_debit: int | None
+ closing_credit: int | None
+ collapsed: frozenset[CollapsedGroup] = frozenset()
+
+ def indexes(self) -> dict[str, int]:
+ """Заданные колонки сумм по именам полей (`opening_debit`, …), в порядке полей."""
+ found: dict[str, int] = {}
+ for field in fields(self):
+ value = getattr(self, field.name)
+ if isinstance(value, int):
+ found[field.name] = value
+ return found
+
+ def as_diagnostics(self) -> dict[str, str]:
+ """Диагностика для `columns` контракта: `{"opening_debit": "E", …}` — только найденные колонки."""
+ return {name: column_letter(index) for name, index in self.indexes().items()}
+
+
+@dataclass(frozen=True)
+class TableHeader:
+ """Блок заголовка таблицы ОСВ и колонки, определённые по нему и по первым строкам данных."""
+
+ first_row: int # строка групп сумм
+ last_row: int # последняя строка блока (включительно): подзаголовки, их merged-продолжение, метки группировок
+ columns: ColumnMap
+ text_col: int
+ inn_col: int | None
+ right_edge: int # последняя колонка таблицы (правый край последней колонки сумм); правее — не таблица
+ extra_cols: dict[int, str] # колонка → заголовок в блоке (или буква колонки, если заголовка нет)
+ group_label_rows: list[int] # строки блока, где в text_col начинается текст группировки («Счет», «Контрагенты»)
+
+
+# --- Текст ячеек ---
+
+
+@lru_cache(maxsize=_NORMALIZE_CACHE_SIZE)
+def _normalized(text: str) -> str:
+ return normalize_text(text)
+
+
+def _search(patterns: Iterable[Pattern], normalized: str) -> re.Match[str] | None:
+ for pattern in patterns:
+ found = pattern.search_normalized(normalized)
+ if found is not None:
+ return found
+ return None
+
+
+def _texts(sheet: Sheet, row: int) -> Iterator[tuple[int, str]]:
+ """`(col, нормализованный текст)` непустых ячеек строки (merged — только origin)."""
+ for col, cell in sheet.row_cells(row):
+ yield col, _normalized(cell.text)
+
+
+# --- Вид листа ---
+
+
+def classify_sheet(sheet: Sheet, rules: Rules) -> SheetKind:
+ """ОСВ — если в первых строках есть признак ведомости; иначе форма ББ/ОФР по её признакам; иначе прочее."""
+ head = [text for row in range(1, min(sheet.n_rows, SHEET_MARKER_ROWS) + 1) for _, text in _texts(sheet, row)]
+ if any(_search(rules.headers.sheet_markers, text) for text in head):
+ return SheetKind.OSV
+ if any(_search(rules.headers.statement_markers, text) for text in head):
+ return SheetKind.STATEMENT
+ return SheetKind.OTHER
+
+
+# --- Заголовок таблицы ---
+
+
+@dataclass(frozen=True)
+class _GroupCell:
+ """Ячейка строки групп, совпавшая с группой сумм; `side` — «Дт/Кт» в той же ячейке (заголовок в одну строку)."""
+
+ group: SumGroup
+ col: int
+ side: Side | None
+
+
+@dataclass(frozen=True)
+class _SumCell:
+ """Найденная колонка суммы: индекс и строки/правый край ячейки заголовка (с учётом merged)."""
+
+ col: int
+ top: int
+ bottom: int
+ right: int
+
+
+def _side(normalized: str, rules: Rules) -> Side | None:
+ if _search(rules.headers.debit, normalized):
+ return "debit"
+ if _search(rules.headers.credit, normalized):
+ return "credit"
+ return None
+
+
+def _group_cells(sheet: Sheet, row: int, rules: Rules) -> list[_GroupCell]:
+ """Ячейки строки, где найдена группа сумм; у каждой ячейки — не больше одной группы (порядок групп — приоритет)."""
+ found: list[_GroupCell] = []
+ for col, text in _texts(sheet, row):
+ for group in GROUP_ORDER:
+ match = _search(rules.headers.groups[group], text)
+ if match is not None:
+ remainder = _LEADING_PUNCTUATION.sub("", text[match.end() :])
+ found.append(_GroupCell(group, col, _side(remainder, rules)))
+ break
+ return found
+
+
+def _sum_cell(sheet: Sheet, row: int, col: int) -> _SumCell:
+ span = sheet.merged_span(row, col)
+ if span is None:
+ return _SumCell(col, row, row, col)
+ return _SumCell(col, span.top, span.bottom, span.right)
+
+
+def _missing_groups_message(sheet: Sheet, found: set[SumGroup]) -> str:
+ missing = ", ".join(f"«{GROUP_TITLES[group]}»" for group in GROUP_ORDER if group not in found)
+ return f"лист «{sheet.name}»: не найден заголовок таблицы ОСВ — нет групп сумм {missing}"
+
+
+def _check_deadline(deadline: Deadline | None, index: int, where: str) -> None:
+ """`deadline.check(where)` на каждой `DEADLINE_CHECK_EVERY_ROWS`-й итерации цикла (с первой); без дедлайна — нет."""
+ if deadline is not None and index % DEADLINE_CHECK_EVERY_ROWS == 0:
+ deadline.check(where)
+
+
+def _find_group_row(sheet: Sheet, rules: Rules, deadline: Deadline | None = None) -> tuple[int, list[_GroupCell]]:
+ """Первая сверху строка с «оборотами» и «сальдо на конец» («начало» — по возможности).
+
+ Строка, где нет одной из обязательных групп (например, преамбула «Сальдо на начало: …» | «Сальдо на конец: …»),
+ пропускается. Не нашлось — `InvalidFormatError` с группами, недостающими в лучшей строке-кандидате.
+ """
+ best: set[SumGroup] = set()
+ for index, row in enumerate(range(1, sheet.n_rows + 1)):
+ _check_deadline(deadline, index, f"лист «{sheet.name}»: поиск заголовка таблицы")
+ cells = _group_cells(sheet, row, rules)
+ groups = {cell.group for cell in cells}
+ if REQUIRED_GROUPS <= groups:
+ return row, cells
+ if len(groups) > len(best):
+ best = groups
+ raise InvalidFormatError(_missing_groups_message(sheet, best))
+
+
+@dataclass(frozen=True)
+class _GroupColumns:
+ debit: _SumCell | None
+ credit: _SumCell | None
+ collapsed: _SumCell | None
+
+
+def _columns_of_group(
+ sheet: Sheet, group_row: int, group_cells: list[_GroupCell], group: SumGroup, rules: Rules
+) -> _GroupColumns:
+ own = [cell for cell in group_cells if cell.group == group]
+ sided = [cell for cell in own if cell.side is not None]
+ if sided:
+ # Заголовок в одну строку: «Нач. сальдо Дт» | «Нач. сальдо Кт»
+ debit = next((_sum_cell(sheet, group_row, c.col) for c in sided if c.side == "debit"), None)
+ credit = next((_sum_cell(sheet, group_row, c.col) for c in sided if c.side == "credit"), None)
+ return _check_sides(sheet, group, debit, credit)
+ title = own[0]
+ title_span = sheet.merged_span(group_row, title.col)
+ if title_span is not None:
+ # Слитое название (в том числе только по вертикали): подзаголовки — строго под его диапазоном
+ right = title_span.right
+ else:
+ following = [cell.col for cell in group_cells if cell.col > title.col]
+ right = min(following) - 1 if following else sheet.n_cols
+ first_sub_row = group_row + 1 if title_span is None else title_span.bottom + 1
+ debit = credit = None
+ for row in range(first_sub_row, group_row + HEADER_MAX_ROWS):
+ for col, text in _texts(sheet, row):
+ if not title.col <= col <= right:
+ continue
+ side = _side(text, rules)
+ if side == "debit" and debit is None:
+ debit = _sum_cell(sheet, row, col)
+ elif side == "credit" and credit is None:
+ credit = _sum_cell(sheet, row, col)
+ if debit is None and credit is None:
+ if group == "turnover":
+ raise InvalidFormatError(
+ f"лист «{sheet.name}»: группа «{GROUP_TITLES[group]}» без колонок «Дебет»/«Кредит» — "
+ "свёрнутыми бывают только сальдо"
+ )
+ return _GroupColumns(None, None, _sum_cell(sheet, group_row, title.col))
+ return _check_sides(sheet, group, debit, credit)
+
+
+def _check_sides(sheet: Sheet, group: SumGroup, debit: _SumCell | None, credit: _SumCell | None) -> _GroupColumns:
+ if debit is None or credit is None:
+ present: Side = "credit" if debit is None else "debit"
+ absent: Side = "debit" if debit is None else "credit"
+ raise InvalidFormatError(
+ f"лист «{sheet.name}»: группа «{GROUP_TITLES[group]}»: найден «{SIDE_TITLES[present]}», "
+ f"нет «{SIDE_TITLES[absent]}»"
+ )
+ return _GroupColumns(debit, credit, None)
+
+
+def _is_label_row(sheet: Sheet, row: int, sums: range, rules: Rules) -> bool:
+ """Строка-метка блока заголовка: левее сумм — текст группировки, «ИНН» или «Субконто», в колонках сумм — пусто.
+
+ Пустые колонки сумм отличают метку «Контрагенты» от строки данных с контрагентом, чьё имя так начинается.
+ """
+ label_patterns = [
+ *(pattern for pattern, _ in rules.groups.mapping),
+ *rules.headers.service_columns["inn"],
+ *rules.headers.header_markers["subkonto"],
+ ]
+ texts = list(_texts(sheet, row))
+ if any(col in sums for col, _ in texts):
+ return False
+ return any(col < sums.start and _search(label_patterns, text) for col, text in texts)
+
+
+def _block_last_row(sheet: Sheet, group_row: int, sum_cells: list[_SumCell], rules: Rules) -> int:
+ """Последняя строка блока: подзаголовки (с merged-продолжением) и подряд идущие строки-метки под строкой групп."""
+ covered = {row for cell in sum_cells for row in range(cell.top, cell.bottom + 1)}
+ sums = range(min(cell.col for cell in sum_cells), max(cell.right for cell in sum_cells) + 1)
+ last = group_row
+ for row in range(group_row + 1, group_row + HEADER_MAX_ROWS):
+ if row in covered or _is_label_row(sheet, row, sums, rules):
+ last = row
+ elif not sheet.is_row_empty(row):
+ break # первая строка данных
+ return last
+
+
+def _data_rows(sheet: Sheet, after: int, limit: int | None = None) -> Iterator[int]:
+ """Непустые строки после `after` (не больше `limit`)."""
+ taken = 0
+ for row in range(after + 1, sheet.n_rows + 1):
+ if limit is not None and taken >= limit:
+ return
+ if not sheet.is_row_empty(row):
+ taken += 1
+ yield row
+
+
+def _text_col(sheet: Sheet, first_row: int, last_row: int, first_sum: int) -> int:
+ """Первая непустая колонка левее сумм по первым строкам данных; без строк данных — по меткам блока."""
+ data = list(_data_rows(sheet, last_row, TEXT_COL_SAMPLE_ROWS))
+ rows = data if data else list(range(first_row, last_row + 1))
+ candidates = [col for row in rows for col, _ in sheet.row_cells(row) if col < first_sum]
+ if not candidates:
+ raise InvalidFormatError(f"лист «{sheet.name}»: не найдена колонка текста строк слева от колонок сумм")
+ return min(candidates)
+
+
+def inn_from_cell(cell: Cell) -> str | None:
+ """ИНН из ячейки: ровно 10 или 12 цифр, иначе `None`
+ (строгое правило: им детектор решает по значениям, что колонка — колонка ИНН).
+
+ Извлечение значения из ячеек уже выбранной колонки (текст с обрамлением «ИНН 3528052718», число с потерянным
+ ведущим нулём) — `numbers.inn_digits`; её вызывает классификатор строк. Смягчать это правило нельзя: обычные
+ текстовые колонки (номера договоров, телефоны) начнут выглядеть как колонка ИНН, а числовая колонка КПП (9 цифр)
+ после дополнения нулём — как колонка ИНН с потерянными нулями (R-24).
+
+ Текст — как есть (после trim), только цифры. Целое число — без дробной части и экспоненты, цифры как есть: 9/11
+ цифр числом здесь не дополняются (ruling R-24).
+ """
+ value = cell.value
+ if isinstance(value, Decimal):
+ if not value.is_finite() or value != value.to_integral_value() or value < 0:
+ return None
+ digits = str(int(value))
+ else:
+ digits = cell.text
+ return digits if digits.isdigit() and len(digits) in INN_LENGTHS else None
+
+
+def _inn_col(sheet: Sheet, block: range, text_col: int, first_sum: int, rules: Rules) -> int | None:
+ """Колонка между текстом и суммами: по заголовку «ИНН» в блоке, иначе по доле значений из 10/12 цифр."""
+ inn_patterns = rules.headers.service_columns["inn"]
+ for row in block:
+ for col, text in _texts(sheet, row):
+ if text_col < col < first_sum and _search(inn_patterns, text):
+ return col
+ values: dict[int, list[Cell]] = {}
+ for row in _data_rows(sheet, block[-1], INN_SAMPLE_ROWS):
+ for col, cell in sheet.row_cells(row):
+ if text_col < col < first_sum:
+ values.setdefault(col, []).append(cell)
+ for col in sorted(values):
+ cells = values[col]
+ inn_count = sum(1 for cell in cells if inn_from_cell(cell) is not None)
+ if inn_count >= INN_MIN_VALUES and inn_count * PERCENT >= INN_SHARE_PERCENT * len(cells):
+ return col
+ return None
+
+
+def _block_title(sheet: Sheet, first_row: int, last_row: int, col: int) -> str | None:
+ """Заголовок колонки в блоке: текст самой нижней непустой ячейки блока ровно в этой колонке."""
+ for row in range(last_row, first_row - 1, -1):
+ text = sheet.cell(row, col).text
+ if text:
+ return text
+ return None
+
+
+def _group_label_rows(sheet: Sheet, first_row: int, last_row: int, text_col: int) -> list[int]:
+ labels: list[int] = []
+ for row in range(first_row, last_row + 1):
+ origin_row, origin_col = sheet.merged_origin(row, text_col)
+ if origin_row == row and sheet.cell(origin_row, origin_col).text:
+ labels.append(row)
+ return labels
+
+
+def find_table_header(sheet: Sheet, rules: Rules, deadline: Deadline | None = None) -> TableHeader:
+ """Блок заголовка таблицы и колонки; не найдено — `InvalidFormatError` с названием ненайденного.
+
+ `deadline` проверяется в циклах по всему листу (поиск строки групп, сбор дополнительных колонок); без него —
+ не проверяется (чистая функция для тестов).
+ """
+ group_row, group_cells = _find_group_row(sheet, rules, deadline)
+ found = {cell.group for cell in group_cells}
+ by_group = {group: _columns_of_group(sheet, group_row, group_cells, group, rules) for group in found}
+
+ def index(group: SumGroup, side: Side) -> int | None:
+ columns = by_group.get(group)
+ if columns is None:
+ return None
+ if columns.collapsed is not None:
+ return columns.collapsed.col if side == "debit" else None
+ cell = columns.debit if side == "debit" else columns.credit
+ return None if cell is None else cell.col
+
+ collapsed: frozenset[CollapsedGroup] = frozenset(
+ group for group in ("opening", "closing") if group in by_group and by_group[group].collapsed is not None
+ )
+ columns = ColumnMap(
+ opening_debit=index("opening", "debit"),
+ opening_credit=index("opening", "credit"),
+ turnover_debit=index("turnover", "debit"),
+ turnover_credit=index("turnover", "credit"),
+ closing_debit=index("closing", "debit"),
+ closing_credit=index("closing", "credit"),
+ collapsed=collapsed,
+ )
+ sum_cells = [
+ cell
+ for group_columns in by_group.values()
+ for cell in (group_columns.debit, group_columns.credit, group_columns.collapsed)
+ if cell is not None
+ ]
+ sum_cols = set(columns.indexes().values())
+ first_sum = min(sum_cols)
+ right_edge = max(cell.right for cell in sum_cells)
+
+ last_row = _block_last_row(sheet, group_row, sum_cells, rules)
+ text_col = _text_col(sheet, group_row, last_row, first_sum)
+ inn_col = _inn_col(sheet, range(group_row, last_row + 1), text_col, first_sum, rules)
+
+ partial = TableHeader(group_row, last_row, columns, text_col, inn_col, right_edge, {}, [])
+ occupied = {*sum_cols, text_col} | ({inn_col} if inn_col is not None else set())
+ extra_cols: dict[int, str] = {}
+ for position, row in enumerate(_data_rows(sheet, last_row)):
+ _check_deadline(deadline, position, f"лист «{sheet.name}»: дополнительные колонки")
+ if is_header_repeat(sheet, row, partial, rules):
+ continue
+ for col, _ in sheet.row_cells(row):
+ if text_col < col <= right_edge and col not in occupied and col not in extra_cols:
+ extra_cols[col] = _block_title(sheet, group_row, last_row, col) or column_letter(col)
+ return TableHeader(
+ first_row=group_row,
+ last_row=last_row,
+ columns=columns,
+ text_col=text_col,
+ inn_col=inn_col,
+ right_edge=right_edge,
+ extra_cols=dict(sorted(extra_cols.items())),
+ group_label_rows=_group_label_rows(sheet, group_row, last_row, text_col),
+ )
+
+
+def first_data_row(sheet: Sheet, header: TableHeader) -> int:
+ """Первая непустая строка после блока заголовка; таблица без строк данных — `n_rows + 1`."""
+ return next(_data_rows(sheet, header.last_row), sheet.n_rows + 1)
+
+
+def _signature(sheet: Sheet, row: int, right_edge: int) -> tuple[tuple[int, str], ...]:
+ return tuple((col, text) for col, text in _texts(sheet, row) if col <= right_edge)
+
+
+def is_header_repeat(sheet: Sheet, row: int, header: TableHeader, rules: Rules) -> bool:
+ """Строка данных — повтор заголовка (печать «по страницам»): две группы сумм или копия строки блока заголовка."""
+ if sheet.is_row_empty(row):
+ return False
+ if len({cell.group for cell in _group_cells(sheet, row, rules)}) >= MIN_GROUPS:
+ return True
+ signature = _signature(sheet, row, header.right_edge)
+ if not signature:
+ return False # только ячейки правее таблицы: не заголовок (и не данные таблицы)
+ return any(
+ signature == _signature(sheet, block_row, header.right_edge)
+ for block_row in range(header.first_row, header.last_row + 1)
+ )
diff --git a/api/src/parser/errors.py b/api/src/parser/errors.py
new file mode 100644
index 0000000..f67e4c4
--- /dev/null
+++ b/api/src/parser/errors.py
@@ -0,0 +1,54 @@
+"""Ожидаемые сбои разбора: каждый несёт код контракта и превращается в `ParseOutcome(status=error)` (design D-6)."""
+
+from typing import ClassVar
+
+from src.contract import ErrorCode
+
+__all__ = [
+ "InvalidDocumentError",
+ "InvalidFormatError",
+ "ParseTimeoutError",
+ "ParserError",
+ "UnreadableError",
+ "UnsupportedFormatError",
+]
+
+
+class ParserError(Exception):
+ """Базовая ошибка разбора: `code` — код контракта, `message` — одна строка для `error.message`."""
+
+ code: ClassVar[ErrorCode] = ErrorCode.RECOGNITION_FAILED
+
+ def __init__(self, message: str) -> None:
+ super().__init__(message)
+ self.message = message
+
+
+class UnreadableError(ParserError):
+ """Книга не открывается библиотекой чтения (повреждённый файл, обрезанный zip/BIFF)."""
+
+ code = ErrorCode.UNREADABLE
+
+
+class UnsupportedFormatError(ParserError):
+ """Сигнатура файла не XLS/XLSX/XLSB или превышен лимит размера."""
+
+ code = ErrorCode.UNSUPPORTED_FORMAT
+
+
+class InvalidFormatError(ParserError):
+ """Лист ОСВ найден, но нет заголовка таблицы или колонок сумм."""
+
+ code = ErrorCode.INVALID_FORMAT
+
+
+class InvalidDocumentError(ParserError):
+ """В книге нет ни одного листа ОСВ."""
+
+ code = ErrorCode.INVALID_DOCUMENT
+
+
+class ParseTimeoutError(ParserError):
+ """Превышено время разбора `PARSE_TIMEOUT_SECONDS`."""
+
+ code = ErrorCode.TIMEOUT
diff --git a/api/src/parser/grid.py b/api/src/parser/grid.py
new file mode 100644
index 0000000..91af5f9
--- /dev/null
+++ b/api/src/parser/grid.py
@@ -0,0 +1,138 @@
+"""Единая модель листа (design D-1): читатели xlsx/xls/xlsb приводят книгу к `Sheet`, детекторы работают только с ним.
+
+Индексы строк и колонок — 1-based, как в Excel (`row_number` контракта). Это и `readers/` — единственные места
+парсера, где допустимы индексы и буквы колонок; буквы нужны только для диагностики (`column_letter`).
+"""
+
+import datetime
+from collections.abc import Mapping
+from dataclasses import dataclass
+from decimal import Decimal
+from functools import cached_property
+from typing import Literal
+
+__all__ = ["EMPTY_CELL", "MAX_INDEXED_AREA", "Cell", "CellKind", "MergedRange", "Sheet", "column_letter"]
+
+CellKind = Literal["empty", "text", "number", "date", "bool"]
+CellValue = str | Decimal | datetime.date | datetime.datetime | bool | None
+
+_ALPHABET_SIZE = 26
+# Диапазоны больше этой площади не раскладываются в индекс «ячейка → диапазон», а проверяются перебором:
+# merged на весь столбец/лист не должен порождать миллионы записей
+MAX_INDEXED_AREA = 10_000
+
+
+@dataclass(frozen=True, slots=True)
+class Cell:
+ """Значение ячейки как пришло из файла (первичка не мутируется) и признаки оформления для каскада иерархии."""
+
+ value: CellValue
+ kind: CellKind
+ bold: bool = False
+ indent: int = 0 # alignment.indent, а при его отсутствии — число ведущих пробелов текста
+
+ @property
+ def text(self) -> str:
+ """Строка для сопоставления: `str(value).strip()`; `None` и строка из пробелов дают ""."""
+ if self.value is None:
+ return ""
+ return str(self.value).strip()
+
+
+EMPTY_CELL = Cell(None, "empty")
+
+
+@dataclass(frozen=True, slots=True)
+class MergedRange:
+ """Объединённый диапазон, границы 1-based включительно; значение лежит в (top, left)."""
+
+ top: int
+ left: int
+ bottom: int
+ right: int
+
+ @property
+ def area(self) -> int:
+ return (self.bottom - self.top + 1) * (self.right - self.left + 1)
+
+ def contains(self, row: int, col: int) -> bool:
+ return self.top <= row <= self.bottom and self.left <= col <= self.right
+
+
+@dataclass(frozen=True)
+class Sheet:
+ """Лист книги: только непустые ячейки, merged-диапазоны, outline level строк, габариты."""
+
+ name: str
+ cells: Mapping[tuple[int, int], Cell] # (row, col) 1-based
+ merged: tuple[MergedRange, ...]
+ row_outline: Mapping[int, int] # только строки с уровнем > 0
+ n_rows: int
+ n_cols: int
+
+ def cell(self, row: int, col: int) -> Cell:
+ return self.cells.get((row, col), EMPTY_CELL)
+
+ def text_at(self, row: int, col: int) -> str:
+ """Текст ячейки; внутри merged-диапазона — текст его origin (левой верхней ячейки)."""
+ return self.cell(*self.merged_origin(row, col)).text
+
+ def merged_origin(self, row: int, col: int) -> tuple[int, int]:
+ span = self.merged_span(row, col)
+ if span is None:
+ return row, col
+ return span.top, span.left
+
+ def merged_span(self, row: int, col: int) -> MergedRange | None:
+ span = self._merged_index.get((row, col))
+ if span is not None:
+ return span
+ for large in self._large_merged:
+ if large.contains(row, col):
+ return large
+ return None
+
+ def row_cells(self, row: int) -> list[tuple[int, Cell]]:
+ """Непустые ячейки строки `(col, cell)` по возрастанию col; ячейки из одних пробелов пропускаются."""
+ return list(self._rows.get(row, ()))
+
+ def is_row_empty(self, row: int) -> bool:
+ return row not in self._rows
+
+ def outline(self, row: int) -> int:
+ return self.row_outline.get(row, 0)
+
+ # Индексы строятся лениво один раз: cached_property пишет в __dict__ экземпляра в обход frozen-__setattr__
+ @cached_property
+ def _rows(self) -> dict[int, tuple[tuple[int, Cell], ...]]:
+ rows: dict[int, list[tuple[int, Cell]]] = {}
+ for (row, col), cell in self.cells.items():
+ if cell.text:
+ rows.setdefault(row, []).append((col, cell))
+ return {row: tuple(sorted(items, key=lambda item: item[0])) for row, items in rows.items()}
+
+ @cached_property
+ def _merged_index(self) -> dict[tuple[int, int], MergedRange]:
+ index: dict[tuple[int, int], MergedRange] = {}
+ for span in self.merged:
+ if span.area > MAX_INDEXED_AREA:
+ continue
+ for row in range(span.top, span.bottom + 1):
+ for col in range(span.left, span.right + 1):
+ index[(row, col)] = span
+ return index
+
+ @cached_property
+ def _large_merged(self) -> tuple[MergedRange, ...]:
+ return tuple(span for span in self.merged if span.area > MAX_INDEXED_AREA)
+
+
+def column_letter(index: int) -> str:
+ """Буква колонки Excel по 1-based индексу (1 → "A", 27 → "AA"). Только для диагностики `columns`."""
+ if index < 1:
+ raise ValueError(f"индекс колонки 1-based, получено {index}")
+ letters: list[str] = []
+ while index:
+ index, remainder = divmod(index - 1, _ALPHABET_SIZE)
+ letters.append(chr(ord("A") + remainder))
+ return "".join(reversed(letters))
diff --git a/api/src/parser/header.py b/api/src/parser/header.py
new file mode 100644
index 0000000..f645d79
--- /dev/null
+++ b/api/src/parser/header.py
@@ -0,0 +1,373 @@
+"""Шапка документа и схема группировок ОСВ (`05-domain-rules.md` §1.1, §1.5; spec «Шапка документа и схема…»).
+
+Область шапки — строки выше заголовка таблицы. Организация, счёт, период, тип данных и валюта берутся по меткам
+из `rules/osv_headers.yaml` (`header_markers`), квартал и признак нарастающего итога — по `rules/periods.yaml`,
+тип группировки — по `rules/osv_groups.yaml`. Ни одна позиция ячейки заранее не известна; отсутствие поля —
+пустая строка (для полей контракта-строк) или `None` и предупреждение на русском.
+"""
+
+import bisect
+import re
+from collections.abc import Iterable
+from dataclasses import dataclass
+from typing import Any, Literal
+
+from src.contract.common import DataType
+from src.contract.result import OsvGroup
+from src.parser.detect import TableHeader
+from src.parser.grid import Sheet
+from src.parser.rules import GroupType, Pattern, Rules, first_match, normalize_text
+
+__all__ = ["DocHeader", "build_group_schema", "parse_doc_header"]
+
+Quarter = Literal["Q1", "Q2", "Q3", "Q4"]
+HeaderVariant = Literal[1, 2]
+
+# Код счёта начинается с цифры и идёт до пробела или запятой: «60», «58.03», «76.07.1»
+_ACCOUNT_CODE = re.compile(r"\d[^\s,;]*")
+# Метка отделяется от значения двоеточием («Валюта БУ, НУ: RUB»); перечисление субконто — запятыми
+_LABEL_SEPARATOR = ":"
+_LIST_SEPARATOR = ","
+# Знаки, которыми код счёта может заканчиваться в тексте, но которые кодом не являются
+_CODE_TRAILING = ".,;:"
+
+
+@dataclass(frozen=True)
+class DocHeader:
+ """Поля шапки ОСВ (`osvDocument` контракта); `warnings` — что не найдено или не распознано."""
+
+ organization_name: str
+ account_code_raw: str
+ period_text: str
+ period_year: int | None
+ period_quarter: Quarter | None
+ is_cumulative: bool | None
+ data_type: DataType | None
+ currency: str | None
+ header_variant: HeaderVariant
+ warnings: tuple[str, ...]
+
+
+def _group(group_type: GroupType, name: str | None = None, *, has_inn: bool = False) -> OsvGroup:
+ """Группировка контракта; необязательные поля без значения опускаются (явный `null` схема не допускает)."""
+ fields: dict[str, Any] = {"type": group_type}
+ if name:
+ fields["name"] = name
+ if has_inn:
+ fields["has_inn"] = True
+ return OsvGroup(**fields)
+
+
+@dataclass(frozen=True)
+class _HeadCell:
+ row: int
+ col: int
+ text: str
+
+
+# --- Текст ---
+
+
+def _source_offset(text: str, normalized_offset: int) -> int:
+ """Смещение в исходном тексте, с которого начинается хвост нормализованного текста с позиции `normalized_offset`.
+
+ Позиции `Match` относятся к `normalize_text(text)` (нижний регистр, схлопнутые пробелы), а в результат нужен
+ исходный текст («3 квартал 2024 г.», не «3 квартал 2024 г.» в нижнем регистре). Нормализация префикса — префикс
+ нормализации целого, поэтому длина нормализованного префикса монотонна по длине исходного.
+ """
+ # Пробелы сразу после совпадения (`\s+` в метке) нормализация префикса отбрасывает — не считаем их в позиции
+ target = len(normalize_text(text)[:normalized_offset].rstrip())
+ return bisect.bisect_left(range(len(text) + 1), target, key=lambda size: len(normalize_text(text[:size])))
+
+
+def _tail(text: str, match: re.Match[str]) -> str:
+ """Исходный текст после совпадения (позиции `match` — в нормализованном тексте)."""
+ return text[_source_offset(text, match.end()) :].strip()
+
+
+def _find(patterns: Iterable[Pattern], text: str) -> re.Match[str] | None:
+ for pattern in patterns:
+ match = pattern.search(text)
+ if match is not None:
+ return match
+ return None
+
+
+def _value_after_colon(tail: str) -> str:
+ """Значение метки: текст после двоеточия, а без двоеточия — весь хвост («Валюта БУ, НУ: RUB» -> «RUB»)."""
+ _, separator, value = tail.partition(_LABEL_SEPARATOR)
+ return (value if separator else tail).strip()
+
+
+# --- Область шапки ---
+
+
+def _head_cells(sheet: Sheet, header: TableHeader) -> list[_HeadCell]:
+ """Текстовые ячейки выше заголовка таблицы по строкам сверху вниз, слева направо (числа и даты — не текст)."""
+ return [
+ _HeadCell(row, col, cell.text)
+ for row in range(1, header.first_row)
+ for col, cell in sheet.row_cells(row)
+ if cell.kind == "text"
+ ]
+
+
+def _is_structural_marker(cell: _HeadCell, rules: Rules) -> bool:
+ """Структурные метки шапки: лист, счёт, тип данных, параметры, субконто.
+
+ Голые «за» и «валюта» меткой не считаются: это обычные слова, которые могут стоять в названии организации
+ («ООО «За Родину»», «Валюта Трейд»). Период и валюта опознаются отдельно (`_find_period`, `_currency_cells`).
+ """
+ markers = rules.headers.header_markers
+ patterns = [
+ *rules.headers.sheet_markers,
+ *markers["account"],
+ *markers["data_type"],
+ *markers["org_params"],
+ *markers["subkonto"],
+ ]
+ return _find(patterns, cell.text) is not None
+
+
+def _is_title(cell: _HeadCell, rules: Rules) -> bool:
+ """Строка названия ведомости («Оборотно-сальдовая ведомость по счету … за …»): признак листа или метка счёта."""
+ patterns = [*rules.headers.sheet_markers, *rules.headers.header_markers["account"]]
+ return _find(patterns, cell.text) is not None
+
+
+def _currency_cells(cells: list[_HeadCell], rules: Rules) -> list[_HeadCell]:
+ """Строки-метки валюты: «Валюта БУ, НУ: RUB» (с двоеточием; «Валюта Трейд» — не метка)."""
+ patterns = rules.headers.header_markers["currency"]
+ return [cell for cell in cells if _LABEL_SEPARATOR in cell.text and _find(patterns, cell.text) is not None]
+
+
+@dataclass(frozen=True)
+class _PeriodHit:
+ cell: _HeadCell
+ text: str
+
+
+def _cut_period(tail: str, rules: Rules) -> str:
+ """Конец периода — «г.»/«год» включительно: самое короткое начало хвоста, которое словарь периодов узнаёт.
+
+ Диапазон дат «01.01.2025 г. - 31.03.2025 г.» первым «г.» не обрезается: «01.01.2025 г.» — не период. Если ни одно
+ начало не узнано, период — весь хвост до конца строки.
+ """
+ start = 0
+ while start < len(tail):
+ end = rules.periods.end.search(tail[start:])
+ if end is None:
+ break
+ start += _source_offset(tail[start:], end.end())
+ candidate = tail[:start].strip()
+ if rules.periods.match(candidate) is not None:
+ return candidate
+ return tail
+
+
+def _period_candidates(cells: list[_HeadCell], rules: Rules) -> list[_PeriodHit]:
+ """Ячейки с меткой периода («за …», «Период: …») и текст после метки; пустой текст — не кандидат.
+
+ Какой из кандидатов — период, решает `_find_period`: «за» в названии организации не должно его перехватывать.
+ """
+ hits: list[_PeriodHit] = []
+ for cell in cells:
+ match = _find(rules.headers.header_markers["period"], cell.text)
+ if match is None:
+ continue
+ tail = _tail(cell.text, match)
+ if tail:
+ hits.append(_PeriodHit(cell, _cut_period(tail, rules)))
+ return hits
+
+
+def _is_recognised(text: str, rules: Rules) -> bool:
+ return rules.periods.match(text) is not None or rules.periods.year.search(text) is not None
+
+
+def _find_period(cells: list[_HeadCell], rules: Rules) -> _PeriodHit | None:
+ """Строка периода: сначала с опознанным периодом, затем строка названия ведомости или метка с двоеточием."""
+ hits = _period_candidates(cells, rules)
+ recognised = next((hit for hit in hits if _is_recognised(hit.text, rules)), None)
+ if recognised is not None:
+ return recognised
+ return next((hit for hit in hits if _is_title(hit.cell, rules) or _LABEL_SEPARATOR in hit.cell.text), None)
+
+
+def _organization(cells: list[_HeadCell], rules: Rules, period: _PeriodHit | None) -> _HeadCell | None:
+ """Первая текстовая ячейка, не являющаяся меткой шапки, строкой периода или меткой валюты."""
+ taken = [cell for cell in (None if period is None else period.cell, *_currency_cells(cells, rules)) if cell]
+ return next(
+ (cell for cell in cells if cell not in taken and not _is_structural_marker(cell, rules)),
+ None,
+ )
+
+
+def _account_code(cells: list[_HeadCell], rules: Rules) -> str | None:
+ for cell in cells:
+ match = _find(rules.headers.header_markers["account"], cell.text)
+ if match is None:
+ continue
+ code = _ACCOUNT_CODE.match(_tail(cell.text, match))
+ if code is not None:
+ return code.group(0).rstrip(_CODE_TRAILING)
+ return None
+
+
+def _value_in_row(sheet: Sheet, cell: _HeadCell, patterns: list[Pattern]) -> str:
+ """Значение метки: после двоеточия в той же ячейке, а если там пусто — в следующей ячейке строки."""
+ match = _find(patterns, cell.text)
+ value = _value_after_colon(_tail(cell.text, match)) if match is not None else ""
+ if value:
+ return value
+ neighbours = [other.text for col, other in sheet.row_cells(cell.row) if col > cell.col]
+ return neighbours[0] if neighbours else ""
+
+
+def _labelled_value(sheet: Sheet, cells: list[_HeadCell], patterns: list[Pattern]) -> str | None:
+ """Значение первой строки с меткой (`None` — метки нет)."""
+ cell = next((cell for cell in cells if _find(patterns, cell.text) is not None), None)
+ return None if cell is None else _value_in_row(sheet, cell, patterns)
+
+
+def _subkonto_items(cells: list[_HeadCell], rules: Rules) -> list[str] | None:
+ """Вариант 2: «Детализация по субсчетам, субконто: Контрагенты, Договоры» -> `["Контрагенты", "Договоры"]`."""
+ for cell in cells:
+ if _find(rules.headers.header_markers["subkonto"], cell.text) is None or _LABEL_SEPARATOR not in cell.text:
+ continue
+ _, _, listing = cell.text.partition(_LABEL_SEPARATOR)
+ items = [item.strip() for item in listing.split(_LIST_SEPARATOR) if item.strip()]
+ if items:
+ return items
+ return None
+
+
+# --- Шапка документа ---
+
+
+@dataclass(frozen=True)
+class _Period:
+ year: int | None
+ quarter: Quarter | None
+ is_cumulative: bool | None
+
+
+def _parse_period(text: str, rules: Rules, warnings: list[str]) -> _Period:
+ rule = rules.periods.match(text)
+ year = rules.periods.year.search(text)
+ if rule is None:
+ warnings.append(f"период «{text}»: квартал и признак нарастающего итога не определены по словарю периодов")
+ if year is None:
+ warnings.append(f"период «{text}»: год не найден")
+ # ASSUMPTION(OQ-02): is_cumulative=false для Q2–Q4 определяется по тексту «N квартал»; ядро может переопределить
+ # по данным (opening Q3 == opening Q1 -> нарастающая)
+ return _Period(
+ year=None if year is None else int(year.group(0)),
+ quarter=None if rule is None else rule.quarter,
+ is_cumulative=None if rule is None else rule.is_cumulative,
+ )
+
+
+def _parse_data_type(sheet: Sheet, cells: list[_HeadCell], rules: Rules, warnings: list[str]) -> DataType | None:
+ value = _labelled_value(sheet, cells, rules.headers.header_markers["data_type"])
+ if value is None:
+ warnings.append("тип выводимых данных (БУ/НУ/сумма) в шапке не найден")
+ return None
+ data_type = first_match(rules.data_types.header, value)
+ if data_type is None:
+ warnings.append(f"тип выводимых данных «{value}» не распознан")
+ return data_type
+
+
+def _parse_currency(sheet: Sheet, cells: list[_HeadCell], rules: Rules, warnings: list[str]) -> str | None:
+ patterns = rules.headers.header_markers["currency"]
+ candidates = _currency_cells(cells, rules)
+ if not candidates:
+ warnings.append("валюта в шапке не найдена")
+ return None
+ values = [_value_in_row(sheet, cell, patterns) for cell in candidates]
+ # Метка валюты, значение которой опознано, важнее первой попавшейся
+ code = next((found for found in (rules.data_types.currency_code(value) for value in values) if found), None)
+ if code is None:
+ warnings.append(f"валюта «{values[0]}» не распознана")
+ return code
+
+
+def parse_doc_header(sheet: Sheet, header: TableHeader, rules: Rules) -> DocHeader:
+ """Шапка документа по области выше заголовка таблицы; что не найдено — `""`/`None` и предупреждение."""
+ cells = _head_cells(sheet, header)
+ warnings: list[str] = []
+
+ period_hit = _find_period(cells, rules)
+ organization = _organization(cells, rules, period_hit)
+ if organization is None:
+ warnings.append("наименование организации в шапке не найдено")
+ account = _account_code(cells, rules)
+ if account is None:
+ warnings.append("код счёта (после «по счету») в шапке не найден")
+ period_text = None if period_hit is None else period_hit.text
+ if period_text is None:
+ warnings.append("период (после «за» или «Период:») в шапке не найден")
+ period = _Period(None, None, None)
+ else:
+ period = _parse_period(period_text, rules, warnings)
+
+ data_type = _parse_data_type(sheet, cells, rules, warnings)
+ currency = _parse_currency(sheet, cells, rules, warnings)
+ variant: HeaderVariant = 1 if _subkonto_items(cells, rules) is None else 2
+ return DocHeader(
+ organization_name="" if organization is None else organization.text,
+ account_code_raw=account or "",
+ period_text=period_text or "",
+ period_year=period.year,
+ period_quarter=period.quarter,
+ is_cumulative=period.is_cumulative,
+ data_type=data_type,
+ currency=currency,
+ header_variant=variant,
+ warnings=tuple(warnings),
+ )
+
+
+# --- Схема группировок ---
+
+
+def _row_has_inn(sheet: Sheet, row: int, rules: Rules) -> bool:
+ """В строке блока заголовка есть ячейка «ИНН» (отдельная или в тексте метки: «Контрагенты, ИНН»)."""
+ patterns = rules.headers.service_columns["inn"]
+ return any(_find(patterns, cell.text) is not None for _, cell in sheet.row_cells(row))
+
+
+def _first_group_name(sheet: Sheet, header: TableHeader) -> str | None:
+ if not header.group_label_rows:
+ return None
+ return sheet.text_at(header.group_label_rows[0], header.text_col) or None
+
+
+def _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> list[OsvGroup]:
+ groups: list[OsvGroup] = []
+ for index, row in enumerate(header.group_label_rows):
+ label = sheet.text_at(row, header.text_col)
+ # Первая группировка — всегда ACCOUNT, как бы она ни называлась («Счет», «Подразделение», «Субконто»)
+ group_type: GroupType = "ACCOUNT" if index == 0 else rules.groups.classify(label)
+ groups.append(_group(group_type, label, has_inn=_row_has_inn(sheet, row, rules)))
+ return groups or [_group("ACCOUNT")]
+
+
+def _variant2_groups(sheet: Sheet, header: TableHeader, rules: Rules, items: list[str]) -> list[OsvGroup]:
+ groups = [_group("ACCOUNT", _first_group_name(sheet, header))]
+ inn_pending = header.inn_col is not None
+ for item in items:
+ group_type = rules.groups.classify(item)
+ has_inn = inn_pending and group_type == "COUNTERPARTY"
+ inn_pending = inn_pending and not has_inn
+ groups.append(_group(group_type, item, has_inn=has_inn))
+ return groups
+
+
+def build_group_schema(sheet: Sheet, header: TableHeader, rules: Rules) -> tuple[list[OsvGroup], HeaderVariant]:
+ """Группировки в порядке вложенности (первая — ACCOUNT) и вариант шапки (1 — строки блока, 2 — «субконто: …»)."""
+ items = _subkonto_items(_head_cells(sheet, header), rules)
+ if items is None:
+ return _variant1_groups(sheet, header, rules), 1
+ return _variant2_groups(sheet, header, rules, items), 2
diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py
new file mode 100644
index 0000000..ab8310f
--- /dev/null
+++ b/api/src/parser/hierarchy.py
@@ -0,0 +1,287 @@
+"""Иерархия строк ОСВ — каскад стратегий (spec «Иерархия строк — каскад стратегий», design D-3, §1.0 п. 5, §1.3).
+
+Стратегии — чистые функции над `BodyRow` (строки данных без листа): `outline` → `indent` → `account_pattern` →
+`sums`. Каждая возвращает уровни `{row: level}` или `None`, если неприменима; первая, давшая непротиворечивое дерево
+(`is_consistent`), побеждает, её имя попадает в `Levels.strategy` (`hierarchy=<имя>` в `metrics.warnings`).
+
+Уровни и родители считаются по порядку строк и уровням, а не по номерам и текстам: два одноимённых контрагента под
+разными субсчетами получают разных родителей (Review Focus 3). Строка «Итого» в стратегии не передаётся: она —
+контрольная сумма документа, всегда уровень 0 без родителя, и не может стать ребёнком последнего счёта.
+"""
+
+from collections.abc import Iterator, Mapping, Sequence
+from dataclasses import dataclass
+from decimal import Decimal
+from typing import Protocol
+
+from src.parser.deadline import Deadline
+from src.parser.errors import InvalidFormatError
+
+__all__ = [
+ "STRATEGIES",
+ "TRIVIAL_STRATEGY",
+ "BodyRow",
+ "Levels",
+ "Strategy",
+ "is_consistent",
+ "parents",
+ "resolve_levels",
+ "strategy_account_pattern",
+ "strategy_indent",
+ "strategy_outline",
+ "strategy_sums",
+]
+
+ROOT_LEVEL = 0
+# Стратегия по оформлению (outline, отступ) применима, только если различает хотя бы два уровня
+MIN_DISTINCT_LEVELS = 2
+# Допуск сравнения «сумма детей = родитель» в стратегии `sums` — тот же, что у контрольных сумм §1.4
+SUM_TOLERANCE = Decimal("0.01")
+# Каскад нужен, только если строк (кроме «Итого») хотя бы две; одна строка или ни одной — тривиальное дерево
+# из корней (пустая ОСВ: только «Итого», в том числе «Итого» без сумм, не распознанное как TOTAL)
+MIN_ROWS_FOR_CASCADE = 2
+TRIVIAL_STRATEGY = "trivial"
+# Кооперативный дедлайн внутри стратегии (design D-8): проверка каждые N строк или N шагов перебора поддеревьев
+DEADLINE_CHECK_EVERY = 500
+_ZERO = Decimal(0)
+
+
+@dataclass(frozen=True)
+class BodyRow:
+ """Строка данных до классификации: текст, признак счёта, оформление и шесть сумм (по `ColumnMap`)."""
+
+ row: int
+ text: str
+ is_account: bool
+ account_rank: int | None # число сегментов кода счёта: 60 → 1, 60.01 → 2, 60.01.1 → 3
+ bold: bool
+ indent: int # alignment.indent или число ведущих пробелов (единицы разные — сравниваются только ранги)
+ outline: int
+ sums: tuple[Decimal | None, ...]
+
+
+@dataclass(frozen=True)
+class Levels:
+ """Уровни строк, их родители (`None` — корень) и имя выбравшей их стратегии."""
+
+ by_row: dict[int, int]
+ parent: dict[int, int | None]
+ strategy: str
+
+
+class Strategy(Protocol):
+ """Стратегия каскада: уровни `{row: level}` или `None`, если неприменима; `deadline` проверяется изнутри
+ (каждые `DEADLINE_CHECK_EVERY` строк/шагов), без него — не проверяется (чистая функция для тестов).
+ """
+
+ def __call__(self, rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: ...
+
+
+class _Pacer:
+ """Счётчик шагов: каждые `DEADLINE_CHECK_EVERY` вызовов `tick()` — `deadline.check(where)`."""
+
+ def __init__(self, deadline: Deadline | None, where: str) -> None:
+ self._deadline = deadline
+ self._where = where
+ self._steps = 0
+
+ def tick(self) -> None:
+ self._steps += 1
+ if self._deadline is not None and self._steps % DEADLINE_CHECK_EVERY == 0:
+ self._deadline.check(self._where)
+
+
+def _paced(rows: Sequence[BodyRow], deadline: Deadline | None, where: str) -> Iterator[BodyRow]:
+ """Строки по порядку с проверкой дедлайна каждые `DEADLINE_CHECK_EVERY` строк."""
+ pacer = _Pacer(deadline, where)
+ for item in rows:
+ pacer.tick()
+ yield item
+
+
+def _ranks(values: Sequence[int]) -> dict[int, int] | None:
+ """Значение → его ранг среди различных значений (0, 1, 2…); `None`, если различных меньше двух."""
+ distinct = sorted(set(values))
+ if len(distinct) < MIN_DISTINCT_LEVELS:
+ return None
+ return {value: rank for rank, value in enumerate(distinct)}
+
+
+def strategy_outline(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
+ """Outline level Excel (группировки строк), сдвинутый так, чтобы минимальный уровень стал 0.
+
+ Сдвиг нужен, когда вся таблица целиком вложена в группировку (уровни 1, 2, … без 0); пропуски уровней
+ сохраняются как в файле. `None`, если различных уровней меньше двух.
+ """
+ outlines = [item.outline for item in _paced(rows, deadline, "иерархия (outline)")]
+ if len(set(outlines)) < MIN_DISTINCT_LEVELS:
+ return None
+ base = min(outlines)
+ return {item.row: item.outline - base for item in rows}
+
+
+def strategy_indent(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
+ """Отступ ячейки текста: уровень — ранг отступа среди различных отступов листа.
+
+ `indent` приходит либо как `alignment.indent`, либо как число ведущих пробелов (T2.4), поэтому шаг отступа
+ не предполагается: (0, 2, 4), (0, 1, 2) и (0, 3, 7) дают одинаковые уровни 0, 1, 2. `None`, если различных
+ отступов меньше двух.
+ """
+ ranks = _ranks([item.indent for item in _paced(rows, deadline, "иерархия (indent)")])
+ if ranks is None:
+ return None
+ return {item.row: ranks[item.indent] for item in rows}
+
+
+def strategy_account_pattern(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
+ """Шаблон кода счёта и жирность.
+
+ Строка-счёт: уровень = ранг кода (число сегментов) − минимальный ранг среди счетов листа (60 → 0, 60.01 → 1,
+ если 60 есть; если таблица начинается с субсчетов, они — корни). Не-счёт: уровень последнего счёта + 1.
+
+ ASSUMPTION (правило брифа Задачи 6, §1.0 п. 5 «по жирности»): жирная не-счётная строка — заголовок группировки
+ (например, контрагент); жирные не-счётные строки внутри одного счёта — соседи (не глубже друг друга), а
+ следующие за жирной обычные строки — её дети (+1). Если жирных не-счётных строк под счётом нет, все не-счётные
+ строки — дети счёта одного уровня; глубже одной группировки по жирности стратегия не различает (CURRENCY/QUANTITY
+ и договоры под обычным контрагентом окажутся его соседями — это поймают контрольные суммы §1.4).
+ Не-счётные строки до первого счёта считаются корнями. `None`, если на листе нет ни одного счёта.
+ """
+ ranks = [item.account_rank for item in rows if item.is_account and item.account_rank is not None]
+ if not ranks:
+ return None
+ base = min(ranks)
+ by_row: dict[int, int] = {}
+ account_level: int | None = None
+ group_level: int | None = None # уровень последней жирной не-счётной строки под текущим счётом
+ for item in _paced(rows, deadline, "иерархия (account_pattern)"):
+ if item.is_account and item.account_rank is not None:
+ level = item.account_rank - base
+ account_level, group_level = level, None
+ else:
+ child_level = ROOT_LEVEL if account_level is None else account_level + 1
+ if item.bold:
+ level = child_level
+ group_level = level
+ elif group_level is not None:
+ level = group_level + 1
+ else:
+ level = child_level
+ by_row[item.row] = level
+ return by_row
+
+
+def _vector(item: BodyRow) -> tuple[Decimal, ...]:
+ return tuple(_ZERO if value is None else value for value in item.sums)
+
+
+def _add(left: tuple[Decimal, ...], right: tuple[Decimal, ...]) -> tuple[Decimal, ...]:
+ return tuple(a + b for a, b in zip(left, right, strict=True))
+
+
+def _matches(left: tuple[Decimal, ...], right: tuple[Decimal, ...]) -> bool:
+ return all(abs(a - b) <= SUM_TOLERANCE for a, b in zip(left, right, strict=True))
+
+
+def strategy_sums(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
+ """Последнее средство: строка — родитель следующих поддеревьев, пока их сумма не совпадёт с её суммами.
+
+ Жадно и без рекурсии: поддеревья строятся с конца листа (`end[i]` — граница поддерева строки `i`), строка `i`
+ перебирает следующие поддеревья подряд и становится родителем на первом совпадении по всем шести колонкам
+ (± `SUM_TOLERANCE`; пусто = 0). Строка без сумм (все пусто/0) родителем не бывает. Соседи с равными суммами
+ могут быть приняты за родителя и ребёнка — это ограничение жадного правила; результат всё равно проходит
+ контрольные суммы §1.4. `None`, если ни одна строка не стала родителем.
+
+ Худший случай (ни одна строка не сходится) — O(n²) шагов перебора: каждая строка проходит все следующие
+ поддеревья до конца листа. Поэтому дедлайн проверяется во внутреннем цикле каждые `DEADLINE_CHECK_EVERY` шагов,
+ и такой файл завершается `TIMEOUT`, а не висит.
+ """
+ count = len(rows)
+ vectors = [_vector(item) for item in rows]
+ zero = tuple(_ZERO for _ in vectors[0]) if vectors else ()
+ end = [index + 1 for index in range(count)] # граница (исключительно) поддерева строки
+ has_children = False
+ pacer = _Pacer(deadline, "иерархия (sums): перебор поддеревьев")
+ for index in reversed(range(count)):
+ pacer.tick()
+ target = vectors[index]
+ if all(value == _ZERO for value in target):
+ continue
+ accumulated = zero
+ position = index + 1
+ while position < count:
+ pacer.tick()
+ accumulated = _add(accumulated, vectors[position])
+ position = end[position]
+ if _matches(accumulated, target):
+ end[index] = position
+ has_children = True
+ break
+ if not has_children:
+ return None
+ by_row: dict[int, int] = {}
+ open_ends: list[int] = [] # границы поддеревьев открытых предков
+ for index, item in enumerate(rows):
+ while open_ends and open_ends[-1] <= index:
+ open_ends.pop()
+ by_row[item.row] = len(open_ends)
+ if end[index] > index + 1:
+ open_ends.append(end[index])
+ return by_row
+
+
+STRATEGIES: tuple[tuple[str, Strategy], ...] = (
+ ("outline", strategy_outline),
+ ("indent", strategy_indent),
+ ("account_pattern", strategy_account_pattern),
+ ("sums", strategy_sums),
+)
+
+
+def is_consistent(rows: Sequence[BodyRow], levels: Mapping[int, int], total_row: int | None) -> bool:
+ """Уровни образуют дерево: у каждой строки есть уровень ≥ 0, первая строка — корень, у каждой строки уровня > 0
+ есть предыдущая строка с меньшим уровнем, «Итого» (если есть) — уровень 0.
+ """
+ lowest_seen: int | None = None
+ for item in rows:
+ level = levels.get(item.row)
+ if level is None or level < ROOT_LEVEL:
+ return False
+ if level > ROOT_LEVEL and (lowest_seen is None or lowest_seen >= level):
+ return False
+ lowest_seen = level if lowest_seen is None else min(lowest_seen, level)
+ return total_row is None or levels.get(total_row) == ROOT_LEVEL
+
+
+def parents(rows: Sequence[BodyRow], by_row: Mapping[int, int]) -> dict[int, int | None]:
+ """Родитель — ближайшая предыдущая строка с меньшим уровнем (`None` для корней); по позициям, не по текстам."""
+ result: dict[int, int | None] = {}
+ stack: list[tuple[int, int]] = [] # (уровень, номер строки) открытых предков
+ for item in rows:
+ level = by_row[item.row]
+ while stack and stack[-1][0] >= level:
+ stack.pop()
+ result[item.row] = stack[-1][1] if stack else None
+ stack.append((level, item.row))
+ return result
+
+
+def resolve_levels(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels:
+ """Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`.
+
+ «Итого» (`total_row`) в стратегии не передаётся и получает уровень 0: это контрольная сумма документа, а не
+ ребёнок последнего счёта. Меньше двух строк, кроме «Итого», — тривиальное дерево корней `TRIVIAL_STRATEGY`.
+ """
+ body = [item for item in rows if item.row != total_row]
+ pinned = {} if total_row is None else {total_row: ROOT_LEVEL}
+ if len(body) < MIN_ROWS_FOR_CASCADE:
+ by_row = {item.row: ROOT_LEVEL for item in rows}
+ return Levels(by_row=by_row, parent=parents(rows, by_row), strategy=TRIVIAL_STRATEGY)
+ for name, strategy in STRATEGIES:
+ deadline.check(f"иерархия ({name})")
+ found = strategy(body, deadline)
+ if found is None:
+ continue
+ by_row = {**found, **pinned}
+ if is_consistent(rows, by_row, total_row):
+ return Levels(by_row=by_row, parent=parents(rows, by_row), strategy=name)
+ raise InvalidFormatError("иерархия строк не определена")
diff --git a/api/src/parser/numbers.py b/api/src/parser/numbers.py
new file mode 100644
index 0000000..abea1fe
--- /dev/null
+++ b/api/src/parser/numbers.py
@@ -0,0 +1,139 @@
+"""Числа ячеек (design D-5; `05-domain-rules.md` §1.0): суммы -> `Decimal` с двумя знаками, ИНН.
+
+`inn_digits` — режим извлечения ИНН для ячеек выбранной колонки (строгое правило детектора + цифры из обрамляющего
+текста); для `counterparty_inn` её вызывает классификатор строк.
+
+`to_money` — единственное место парсера, где суммы квантуются до 0.01 (ROUND_HALF_UP) и где разбирается текст.
+Читатели кладут число как `Decimal(str(value))` без квантования, поэтому float-хвосты (`0.30000000000000004`) приходят
+сюда уже как точная десятичная запись и округляются предсказуемо. `float` в домене не используется: ни `float()`, ни
+двоичной арифметики. Предупреждения — короткие русские строки без номера строки; номер строки добавляет вызывающий.
+"""
+
+import re
+from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
+from typing import NamedTuple
+
+from src.parser.detect import INN_LENGTHS, inn_from_cell
+from src.parser.grid import Cell
+
+__all__ = ["MONEY_Q", "MoneyParse", "inn_digits", "to_money"]
+
+MONEY_Q = Decimal("0.01")
+# ИНН регионов 01–09, записанный числом, теряет ведущий ноль: 9 или 11 цифр дополняются нулём слева (только при
+# извлечении из выбранной колонки ИНН, ruling R-24)
+_INN_LENGTHS_WITHOUT_LEADING_ZERO = frozenset(length - 1 for length in INN_LENGTHS)
+_ZERO = Decimal("0.00")
+
+# numeric(18,2): не больше 16 цифр до запятой. Проверка по порядку числа (`adjusted()`) до квантования — квантование
+# числа с гигантской экспонентой само падает с InvalidOperation
+_MAX_INTEGER_DIGITS = 16
+# Порядок меньше -1000 — не шум округления (1e-12 от вычитания в Excel округляется до 0.00), а заведомо нечисловая
+# запись; граница держит Decimal далеко от пределов контекста
+_MIN_ADJUSTED = -1000
+_WARNING_VALUE_LIMIT = 30
+
+# Прочерки и пустота — «нет значения», не 0 и не ошибка. Это формат записи чисел, а не словарь синонимов
+_NO_VALUE = frozenset({"-", "—", "–", ""})
+_WHITESPACE = re.compile(r"[\s ]+")
+_PARENTHESES = re.compile(r"^\((.*)\)$")
+_NUMBER = re.compile(r"^-?[0-9]+(\.[0-9]+)?([eE][-+]?[0-9]+)?$")
+_MINUS_SIGN = "−"
+# Одна запись цифр в тексте: группы цифр, разделённые только пробелом/NBSP/узким пробелом («3528 052718»), с нецифровым
+# обрамлением («ИНН: …»). Две записи, разделённые иным знаком («№ 12 от 01.02.2025», «12 / 3528052718»), — не ИНН
+_SINGLE_DIGIT_RUN = re.compile(r"^\D*([0-9]+(?:[\s\u00a0\u202f][0-9]+)*)\D*$")
+_DIGIT_GROUP_SEPARATOR = re.compile(r"[\s\u00a0\u202f]")
+
+
+class MoneyParse(NamedTuple):
+ value: Decimal | None
+ warning: str | None
+
+
+def _skip(warning: str | None = None) -> MoneyParse:
+ return MoneyParse(None, warning)
+
+
+def _shorten(raw: str) -> str:
+ return raw if len(raw) <= _WARNING_VALUE_LIMIT else raw[:_WARNING_VALUE_LIMIT] + "…"
+
+
+def _quantize(value: Decimal, shown: str) -> MoneyParse:
+ """Квантование до 0.01 с отсечением нечисел и значений вне numeric(18,2); отрицательный ноль -> 0.00."""
+ if not value.is_finite():
+ return _skip(f"нечисловое значение суммы «{_shorten(shown)}»")
+ if value != 0 and not _MIN_ADJUSTED <= value.adjusted() < _MAX_INTEGER_DIGITS:
+ return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(shown)}»")
+ try:
+ money = value.quantize(MONEY_Q, rounding=ROUND_HALF_UP)
+ except InvalidOperation:
+ return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(shown)}»")
+ if money.adjusted() >= _MAX_INTEGER_DIGITS: # 9999999999999999.999 округлилось до 10^16
+ return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(shown)}»")
+ return MoneyParse(_ZERO if money == 0 else money, None)
+
+
+def _parse_text(raw: str) -> MoneyParse:
+ compact = _WHITESPACE.sub("", raw).replace(",", ".").replace(_MINUS_SIGN, "-")
+ wrapped = _PARENTHESES.match(compact)
+ if wrapped:
+ compact = "-" + wrapped.group(1)
+ if compact in _NO_VALUE:
+ return _skip()
+ if not _NUMBER.match(compact):
+ return _skip(f"нечисловое значение в колонке сумм: «{_shorten(raw.strip())}»")
+ try:
+ return _quantize(Decimal(compact), raw.strip())
+ except InvalidOperation:
+ return _skip(f"значение суммы вне допустимого диапазона: «{_shorten(raw.strip())}»")
+
+
+def to_money(cell: Cell) -> MoneyParse:
+ """Сумма из ячейки: `Decimal` с двумя знаками либо `None` (пусто, прочерк — без предупреждения; мусор — с ним)."""
+ if cell.kind == "empty":
+ return _skip()
+ if cell.kind == "number":
+ # str(): точная десятичная запись; Decimal(str(float)) — и для тестов с «сырыми» int/float в ячейке
+ return _quantize(Decimal(str(cell.value)), str(cell.value))
+ if cell.kind == "text":
+ return _parse_text(str(cell.value))
+ if cell.kind == "bool":
+ return _skip("логическое значение вместо суммы")
+ return _skip("дата вместо суммы")
+
+
+def inn_digits(cell: Cell) -> str | None:
+ """ИНН из ячейки уже выбранной колонки ИНН: единая точка извлечения `counterparty_inn` (Task 8 вызывает её).
+
+ 1. Строгое правило детектора `detect.inn_from_cell` (ровно 10/12 цифр); целое число из 9/11 цифр — ИНН региона
+ 01–09, записанный числом с потерянным ведущим нулём: дополняется нулём слева. Дополнение — только здесь, при
+ извлечении из колонки, уже выбранной по заголовку «ИНН» или по строгому правилу значений (ruling R-24).
+ 2. Текстовая ячейка с обрамлением («ИНН 352819549855», «ИНН: 3528052718», «3528 052718»): в тексте должна быть
+ ровно одна запись цифр — группы, разделённые только пробелом/NBSP/узким пробелом (T7.4); группы склеиваются
+ и принимаются, если цифр ровно 10 или 12. Цифры, разделённые иным знаком («№ 12 от 01.02.2025»,
+ «№ 12 / 3528052718 / …»), — не ИНН (`None`). Ведущий ноль к склеенным цифрам не добавляется (11 цифр -> `None`).
+ Применяется только к ячейкам колонки, уже признанной колонкой ИНН.
+ Числовые ячейки во второй шаг не попадают (`3528052718.5` -> `None`). Мягкий второй шаг нельзя переносить в
+ `inn_from_cell`: она решает, является ли колонка колонкой ИНН, и обычные текстовые колонки (номера договоров,
+ телефоны) не должны под неё подпадать.
+ """
+ strict = inn_from_cell(cell)
+ if strict is not None:
+ return strict
+ if cell.kind == "number":
+ return _restored_leading_zero(cell)
+ if cell.kind != "text":
+ return None
+ found = _SINGLE_DIGIT_RUN.match(cell.text)
+ if found is None:
+ return None
+ digits = _DIGIT_GROUP_SEPARATOR.sub("", found.group(1))
+ return digits if len(digits) in INN_LENGTHS else None
+
+
+def _restored_leading_zero(cell: Cell) -> str | None:
+ """Целое неотрицательное число из 9/11 цифр → 10/12 цифр с ведущим нулём; иначе `None`."""
+ value = cell.value
+ if not isinstance(value, Decimal) or not value.is_finite() or value != value.to_integral_value() or value < 0:
+ return None
+ digits = str(int(value))
+ return digits.zfill(len(digits) + 1) if len(digits) in _INN_LENGTHS_WITHOUT_LEADING_ZERO else None
diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py
new file mode 100644
index 0000000..4eba4aa
--- /dev/null
+++ b/api/src/parser/osv.py
@@ -0,0 +1,260 @@
+"""Оркестратор разбора ОСВ: байты книги → документы, метрики, статус (design D-6, D-8; spec «Листы ОСВ находятся по
+содержимому», «Контрольные суммы определяют статус», «Форматы, лимиты, время»).
+
+Порядок: лимит размера → чтение книги по сигнатуре → для каждого листа вид по содержимому (ОСВ / форма ББ-ОФР / прочее)
+→ лист ОСВ: заголовок таблицы → шапка и схема группировок → строки → каскад иерархии → классификация → контрольные
+суммы §1.4. Листы форм и прочие пропускаются с предупреждением; имя листа только подставляется в `locator` и
+сообщения, поиск по нему не ведётся.
+
+Карта исходов (D-6): нет листов ОСВ → `INVALID_DOCUMENT`; лист ОСВ без заголовка/колонок/иерархии → `INVALID_FORMAT`
+(лист и шаг); книга не открывается → `UNREADABLE`; сигнатура не XLS/XLSX/XLSB, размер больше лимита или превышены
+лимиты чтения (распакованный zip > `ZIP_UNCOMPRESSED_LIMIT_FACTOR × max_bytes`, площадь объединённых ячеек, R-23) →
+`UNSUPPORTED_FORMAT`; дедлайн → `TIMEOUT`; любое другое исключение → `RECOGNITION_FAILED` одной строкой
+`Тип: сообщение` (стек — в лог loguru уровня ERROR; `UNREADABLE` — WARNING без стека). Нарушение §1.4 хотя бы на
+одном листе → `needs_check` всего результата.
+"""
+
+import time
+from collections.abc import Callable, Iterable
+
+from loguru import logger
+
+from src.contract import ErrorCode, FileType, MessageError, Metrics, OsvDocument, ResultStatus
+from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
+from src.parser import ParseOutcome
+from src.parser.checks import run_checks
+from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows
+from src.parser.deadline import Deadline
+from src.parser.detect import SheetKind, TableHeader, classify_sheet, find_table_header
+from src.parser.errors import InvalidDocumentError, InvalidFormatError, ParserError, UnsupportedFormatError
+from src.parser.grid import Sheet, column_letter
+from src.parser.header import DocHeader, build_group_schema, parse_doc_header
+from src.parser.hierarchy import resolve_levels
+from src.parser.readers import read_book
+from src.parser.readers._common import one_line_reason
+from src.parser.readers._limits import ZIP_UNCOMPRESSED_LIMIT_FACTOR
+from src.parser.rules import Rules
+
+__all__ = ["OsvParser"]
+
+LOCATOR_PREFIX = "sheet:"
+INN_COLUMN_KEY = "inn"
+NO_DATA_ROWS_WARNING = "нет строк данных"
+_MS_IN_SECOND = 1000
+
+
+def _sheet_line(sheet: Sheet, text: str) -> str:
+ return f"sheet={sheet.name} {text}"
+
+
+def _unique(items: Iterable[str]) -> list[str]:
+ """Без повторов, в порядке первого появления."""
+ return list(dict.fromkeys(items))
+
+
+def _columns(header: TableHeader) -> dict[str, str]:
+ """Диагностика `columns`: шесть колонок сумм и, если найдена, колонка ИНН (буквы — только здесь)."""
+ columns = header.columns.as_diagnostics()
+ if header.inn_col is not None:
+ columns[INN_COLUMN_KEY] = column_letter(header.inn_col)
+ return columns
+
+
+def _header(doc: DocHeader) -> OsvHeader:
+ return OsvHeader(
+ organization_name=doc.organization_name,
+ organization_inn=None,
+ account_code_raw=doc.account_code_raw,
+ period_text=doc.period_text,
+ period_year=doc.period_year,
+ period_quarter=doc.period_quarter,
+ is_cumulative=doc.is_cumulative,
+ data_type=doc.data_type,
+ currency=doc.currency,
+ header_variant=doc.header_variant,
+ )
+
+
+def _row(item: ClassifiedRow) -> OsvRow:
+ sums = dict(zip(SUM_FIELDS, item.body.sums, strict=True))
+ return OsvRow(
+ row_number=item.body.row,
+ level=item.level,
+ parent_row_number=item.parent,
+ row_value=item.body.text,
+ group_type=item.group_type,
+ group_name=item.group_name,
+ account_code_raw=item.account_code_raw,
+ counterparty_name=item.counterparty_name,
+ counterparty_inn=item.counterparty_inn,
+ data_type=item.data_type,
+ opening_debit=sums["opening_debit"],
+ opening_credit=sums["opening_credit"],
+ turnover_debit=sums["turnover_debit"],
+ turnover_credit=sums["turnover_credit"],
+ closing_debit=sums["closing_debit"],
+ closing_credit=sums["closing_credit"],
+ extra=item.extra,
+ )
+
+
+def _is_ok(checks: OsvChecks) -> bool:
+ return (
+ bool(checks.total_row_matches_root)
+ and not checks.balance_identity_violations
+ and not (checks.parent_sum_violations)
+ )
+
+
+def _data_rows(document: OsvDocument) -> int:
+ return sum(1 for row in document.rows if row.group_type != "TOTAL")
+
+
+def _invalid_format(sheet: Sheet, step: str, exc: InvalidFormatError) -> InvalidFormatError:
+ """Сообщение `INVALID_FORMAT` с листом и шагом; префикс «лист «…»:» детектора не дублируется."""
+ reason = exc.message.removeprefix(f"лист «{sheet.name}»: ")
+ return InvalidFormatError(f"лист «{sheet.name}», шаг «{step}»: {reason}")
+
+
+class OsvParser:
+ """Парсер ОСВ по умолчанию: словари `rules`, лимит размера `max_bytes`, таймаут `timeout_seconds` (D-8).
+
+ `parse` не бросает исключений: любой сбой — `ParseOutcome(status=error)` с кодом контракта.
+ """
+
+ def __init__(
+ self,
+ rules: Rules,
+ *,
+ max_bytes: int,
+ timeout_seconds: float,
+ clock: Callable[[], float] = time.monotonic,
+ ) -> None:
+ self._rules = rules
+ self._max_bytes = max_bytes
+ self._timeout_seconds = timeout_seconds
+ self._clock = clock
+
+ @property
+ def max_bytes(self) -> int:
+ return self._max_bytes
+
+ @property
+ def timeout_seconds(self) -> float:
+ return self._timeout_seconds
+
+ def parse(self, data: bytes, file_type: FileType) -> ParseOutcome:
+ started = self._clock()
+ warnings: list[str] = []
+ try:
+ documents, ok = self._parse_book(data, file_type, warnings)
+ except ParserError as exc:
+ if exc.code is ErrorCode.UNREADABLE:
+ logger.warning("Книга не читается: {reason}", reason=exc.message)
+ return self._error(exc.code, exc.message, warnings, started)
+ except Exception as exc: # D-6: непредвиденное — RECOGNITION_FAILED, а не исключение в воркер
+ reason = one_line_reason(exc)
+ # Ответ — одна строка; стек нужен разработчику, а воркер логирует только код ошибки (I-2)
+ logger.opt(exception=exc).error("Непредвиденная ошибка разбора: {reason}", reason=reason)
+ return self._error(ErrorCode.RECOGNITION_FAILED, reason, warnings, started)
+ metrics = Metrics(
+ duration_ms=self._elapsed_ms(started),
+ rows=sum(_data_rows(document) for document in documents),
+ warnings=warnings,
+ )
+ status = ResultStatus.DONE if ok else ResultStatus.NEEDS_CHECK
+ return ParseOutcome(status=status, documents=tuple(documents), metrics=metrics)
+
+ def parse_sheet(self, sheet: Sheet, deadline: Deadline) -> tuple[OsvDocument, list[str], bool]:
+ """Документ листа ОСВ, его предупреждения (первое — `sheet=<имя> hierarchy=<стратегия>`) и признак
+ «контрольные суммы сошлись»; нет заголовка/колонок/иерархии — `InvalidFormatError` с листом и шагом.
+ """
+ rules = self._rules
+ step = "заголовок таблицы"
+ try:
+ header = find_table_header(sheet, rules, deadline)
+ step = "шапка документа"
+ doc = parse_doc_header(sheet, header, rules)
+ schema, _ = build_group_schema(sheet, header, rules)
+ deadline.check(f"лист «{sheet.name}»: строки таблицы")
+ step = "строки таблицы"
+ body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline)
+ step = "иерархия строк"
+ levels = resolve_levels(body, total_row, deadline)
+ step = "классификация строк"
+ classified = classify_rows(body, levels, schema, header, sheet, doc.data_type, total_row, rules)
+ except InvalidFormatError as exc:
+ raise _invalid_format(sheet, step, exc) from exc
+ deadline.check(f"лист «{sheet.name}»: контрольные суммы")
+ checks, check_warnings = run_checks(classified)
+ document = self._document(sheet=sheet, header=header, doc=doc, schema=schema, rows=classified, checks=checks)
+ warnings = [_sheet_line(sheet, f"hierarchy={levels.strategy}"), *doc.warnings, *body_warnings]
+ if not any(item.group_type != "TOTAL" for item in classified):
+ warnings.append(NO_DATA_ROWS_WARNING)
+ warnings.extend(check_warnings)
+ return document, _unique(warnings), _is_ok(checks)
+
+ # --- внутреннее ---
+
+ def _parse_book(self, data: bytes, file_type: FileType, warnings: list[str]) -> tuple[list[OsvDocument], bool]:
+ if len(data) > self._max_bytes:
+ raise UnsupportedFormatError(
+ f"размер файла {len(data)} байт больше лимита PARSE_MAX_BYTES = {self._max_bytes} байт: книга не "
+ "открывается"
+ )
+ deadline = Deadline(self._timeout_seconds, self._clock)
+ sheets, read_warnings = read_book(
+ data, file_type, max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * self._max_bytes
+ )
+ warnings.extend(_unique(read_warnings))
+ documents: list[OsvDocument] = []
+ ok = True
+ for sheet in sheets:
+ deadline.check(f"лист «{sheet.name}»")
+ kind = classify_sheet(sheet, self._rules)
+ if kind is SheetKind.STATEMENT:
+ warnings.append(_sheet_line(sheet, "skipped: statement (slice 2b)"))
+ continue
+ if kind is SheetKind.OTHER:
+ warnings.append(_sheet_line(sheet, "skipped: нет признаков ОСВ"))
+ continue
+ document, sheet_warnings, sheet_ok = self.parse_sheet(sheet, deadline)
+ documents.append(document)
+ warnings.extend(sheet_warnings)
+ ok = ok and sheet_ok
+ if not documents:
+ raise InvalidDocumentError(f"в книге нет листов ОСВ: листов просмотрено {len(sheets)}")
+ return documents, ok
+
+ @staticmethod
+ def _document( # noqa: PLR0913 - части документа собираются в parse_sheet и передаются явно
+ *,
+ sheet: Sheet,
+ header: TableHeader,
+ doc: DocHeader,
+ schema: list[OsvGroup],
+ rows: list[ClassifiedRow],
+ checks: OsvChecks,
+ ) -> OsvDocument:
+ return OsvDocument(
+ document_type="OSV",
+ locator=f"{LOCATOR_PREFIX}{sheet.name}",
+ header=_header(doc),
+ group_schema=schema,
+ columns=_columns(header),
+ rows=[_row(item) for item in rows],
+ checks=checks,
+ )
+
+ def _elapsed_ms(self, started: float) -> int:
+ return max(0, round((self._clock() - started) * _MS_IN_SECOND))
+
+ def _error(self, code: ErrorCode, message: str, warnings: list[str], started: float) -> ParseOutcome:
+ metrics_fields: dict[str, object] = {"duration_ms": self._elapsed_ms(started)}
+ if warnings:
+ metrics_fields["warnings"] = warnings
+ return ParseOutcome(
+ status=ResultStatus.ERROR,
+ error=MessageError(code=code, message=" ".join(message.split())),
+ metrics=Metrics.model_validate(metrics_fields),
+ )
diff --git a/api/src/parser/readers/__init__.py b/api/src/parser/readers/__init__.py
new file mode 100644
index 0000000..289d74a
--- /dev/null
+++ b/api/src/parser/readers/__init__.py
@@ -0,0 +1,94 @@
+"""Чтение книги Excel в модель `Sheet`: формат определяется по сигнатуре байтов, а не по `file_type` запроса.
+
+PK\\x03\\x04 (zip) → XLSX или XLSB (XLSB — если в архиве есть `xl/workbook.bin`); OLE2 (D0 CF 11 E0 …) → XLS (BIFF8).
+Несовпадение с `file_type` — предупреждение, чтение по сигнатуре. Иная сигнатура → `UnsupportedFormatError`;
+исключение библиотеки чтения → `UnreadableError` с причиной одной строкой.
+
+Лимиты фазы чтения (`_limits.py`, rulings R-23, R-25) проверяются до библиотеки чтения: распакованный размер zip
+(`max_uncompressed_bytes`, по умолчанию `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` из настроек по умолчанию)
+и площадь объединённых ячеек xlsx (потоковый скан всех XML-частей); после чтения — площадь объединённых ячеек любого
+формата (диапазоны, которые индекс `Sheet` раскладывает по ячейкам).
+"""
+
+import io
+import zipfile
+from collections.abc import Callable
+
+from src.contract import FileType
+from src.core.config import Settings
+from src.parser.errors import ParserError, UnreadableError, UnsupportedFormatError
+from src.parser.grid import Sheet
+from src.parser.readers._common import one_line_reason
+from src.parser.readers._limits import (
+ ZIP_UNCOMPRESSED_LIMIT_FACTOR,
+ check_merged_area,
+ check_xlsx_merged_area,
+ check_zip_uncompressed_size,
+)
+from src.parser.readers.xls import read_xls
+from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING, read_xlsb
+from src.parser.readers.xlsx import read_xlsx
+
+__all__ = ["default_uncompressed_limit", "read_book"]
+
+ZIP_SIGNATURE = b"PK\x03\x04"
+OLE2_SIGNATURE = bytes.fromhex("D0CF11E0A1B11AE1")
+XLSB_WORKBOOK_PART = "xl/workbook.bin"
+
+# Формат → (название содержимого для предупреждения, читатель)
+_READERS: dict[FileType, tuple[str, Callable[[bytes], tuple[Sheet, ...]]]] = {
+ "XLSX": ("OOXML", read_xlsx),
+ "XLSB": ("BIFF12", read_xlsb),
+ "XLS": ("BIFF8", read_xls),
+}
+
+
+def default_uncompressed_limit() -> int:
+ """Лимит распакованного zip без явного параметра: `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` по умолчанию."""
+ max_bytes: int = Settings.model_fields["PARSE_MAX_BYTES"].default
+ return ZIP_UNCOMPRESSED_LIMIT_FACTOR * max_bytes
+
+
+def read_book(
+ data: bytes, file_type: FileType, *, max_uncompressed_bytes: int | None = None
+) -> tuple[tuple[Sheet, ...], list[str]]:
+ """Листы книги и предупреждения чтения (несовпадение `file_type`, ограничения xlsb).
+
+ `max_uncompressed_bytes` — лимит распакованного zip (xlsx/xlsb); `OsvParser` передаёт
+ `ZIP_UNCOMPRESSED_LIMIT_FACTOR × max_bytes`. Превышение лимитов чтения — `UnsupportedFormatError`.
+ """
+ limit = default_uncompressed_limit() if max_uncompressed_bytes is None else max_uncompressed_bytes
+ detected = _detect(data, file_type, limit)
+ content, reader = _READERS[detected]
+ warnings: list[str] = []
+ if detected != file_type:
+ warnings.append(f"file_type={file_type}, но содержимое {content}: прочитано как {detected}")
+ try:
+ sheets = reader(data)
+ except ParserError:
+ raise
+ except Exception as exc: # библиотеки чтения бросают что угодно: zipfile, KeyError, struct.error, XLRDError…
+ raise UnreadableError(f"книга не читается ({detected}): {one_line_reason(exc)}") from exc
+ check_merged_area(sheets)
+ if detected == "XLSB":
+ warnings.append(OUTLINE_UNAVAILABLE_WARNING)
+ return sheets, warnings
+
+
+def _detect(data: bytes, file_type: FileType, uncompressed_limit: int) -> FileType:
+ """Формат по сигнатуре; для zip — заодно лимиты распакованного размера и (xlsx) объединённых ячеек."""
+ if data.startswith(OLE2_SIGNATURE):
+ return "XLS"
+ if data.startswith(ZIP_SIGNATURE):
+ try:
+ with zipfile.ZipFile(io.BytesIO(data)) as archive:
+ check_zip_uncompressed_size(archive, uncompressed_limit)
+ detected: FileType = "XLSB" if XLSB_WORKBOOK_PART in set(archive.namelist()) else "XLSX"
+ if detected == "XLSX":
+ check_xlsx_merged_area(archive)
+ except ParserError:
+ raise
+ except Exception as exc: # zipfile, zlib.error, NotImplementedError (метод сжатия), RuntimeError (пароль)…
+ raise UnreadableError(f"повреждённый zip-архив: {one_line_reason(exc)}") from exc
+ return detected
+ raise UnsupportedFormatError(f"содержимое файла не XLS/XLSX/XLSB (file_type={file_type})")
diff --git a/api/src/parser/readers/_common.py b/api/src/parser/readers/_common.py
new file mode 100644
index 0000000..4e457a3
--- /dev/null
+++ b/api/src/parser/readers/_common.py
@@ -0,0 +1,43 @@
+"""Общее для читателей: значение библиотеки → `Cell`, ошибка библиотеки → одна строка причины."""
+
+import datetime
+from decimal import Decimal
+
+from src.parser.grid import Cell
+
+
+def leading_spaces(text: str) -> int:
+ """Число ведущих пробельных символов (1С иногда делает отступ иерархии пробелами, а не alignment).
+
+ У строки из одних пробелов отступа нет: это пустая ячейка, а не сдвинутый текст.
+ """
+ stripped = text.lstrip()
+ return len(text) - len(stripped) if stripped else 0
+
+
+def make_cell(value: object, *, bold: bool = False, indent: int = 0) -> Cell | None:
+ """Ячейка из значения библиотеки чтения; `None` — пустая ячейка (в `Sheet.cells` не попадает).
+
+ Числа — `Decimal(str(value))` без квантования (его делает `numbers.py`); bool проверяется раньше int.
+ Строка из одних пробельных символов (включая NBSP) — пустая ячейка (`None`): «пробелы» = пусто на уровне
+ хранения, а не только в аксессорах `Sheet`. Время без даты — текстом.
+ """
+ if value is None or value == "":
+ return None
+ if isinstance(value, bool):
+ return Cell(value, "bool", bold=bold, indent=indent)
+ if isinstance(value, int | float):
+ return Cell(Decimal(str(value)), "number", bold=bold, indent=indent)
+ if isinstance(value, datetime.date): # datetime.datetime — подкласс date
+ return Cell(value, "date", bold=bold, indent=indent)
+ text = value if isinstance(value, str) else str(value)
+ if not text.strip():
+ return None
+ return Cell(text, "text", bold=bold, indent=indent or leading_spaces(text))
+
+
+def one_line_reason(exc: BaseException) -> str:
+ """Причина сбоя библиотеки одной строкой: `Тип: сообщение` без переводов строк."""
+ detail = " ".join(str(exc).split())
+ name = type(exc).__name__
+ return f"{name}: {detail}" if detail else name
diff --git a/api/src/parser/readers/_limits.py b/api/src/parser/readers/_limits.py
new file mode 100644
index 0000000..20e3c1a
--- /dev/null
+++ b/api/src/parser/readers/_limits.py
@@ -0,0 +1,181 @@
+"""Лимиты фазы чтения (ruling R-23, R-25, находка I-1б): отказ `UNSUPPORTED_FORMAT` до того, как библиотека чтения
+выделит память.
+
+Кооперативный дедлайн внутри openpyxl/xlrd/pyxlsb не работает, а лимит размера файла не ограничивает распакованный
+объём: xlsx в 5 КБ с одним `` заставляет openpyxl создать 5,2 млн объектов ячеек
+(24 с и 1,4 ГБ). Поэтому до чтения:
+
+1. zip (xlsx/xlsb): сумма объявленных несжатых размеров записей (`ZipInfo.file_size`) — не больше
+ `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES`. `zipfile` не отдаёт из записи больше объявленного размера, так
+ что объявленная сумма ограничивает и фактическую распаковку (openpyxl и pyxlsb читают через `zipfile`).
+2. xlsx: потоковый скан `xml.parsers.expat` всех XML-частей архива (R-25); суммарная площадь объединённых диапазонов
+ книги — не больше `MERGED_AREA_LIMIT`, отказ — сразу при превышении, не дочитывая часть. Правила скана повторяют то,
+ что разложит openpyxl (он разбирает листы тем же expat через `xml.etree`), с запасом в сторону отказа:
+ - части: КАЖДАЯ некаталожная запись zip, без отбора по имени или началу содержимого: openpyxl идёт к листу по цели
+ связи в `workbook.xml.rels` и ни каталог, ни расширение части не проверяет, а начало XML-документа можно
+ отодвинуть пробелами сколь угодно далеко (прежний отбор по первым 64 байтам обходился, I-1б); бинарную часть
+ expat отвергает на первых байтах; порядок — по имени (детерминированно);
+ - элемент: локальное имя `mergeCell` в ЛЮБОМ пространстве имён (и без него). openpyxl находит контейнер
+ `mergeCells` только в основном пространстве SpreadsheetML, но дочерние `mergeCell` берёт по локальному имени
+ (`Serialisable.from_tree`) — ограничение основным пространством было бы обходом; лишний счёт элемента вне листа
+ безопасен (лишь строже);
+ - атрибут: только `ref` без префикса (атрибуты с префиксом openpyxl отбрасывает); ссылки на символы (`2`) и
+ внутренние сущности раскрывает парсер, `>` в кавычках — не конец тега;
+ - площадь — по строке `ref` (`CellRange` openpyxl, те же правила разбора), ячейки не перебираются: диапазон
+ `A1:XFD1048576` стоит один шаг; ref, который openpyxl не разберёт (или его нет), — `UnreadableError`;
+ - битый XML в части `*.xml` — `UnreadableError` (openpyxl упал бы на ней же, если это его часть); в части с
+ другим именем (картинки, printerSettings, VML, неизвестные данные) — конец скана этой части без отказа: openpyxl
+ её либо не разбирает, либо упадёт на той же ошибке до раскладки объединений (они раскладываются после разбора
+ всего листа), а найденная до ошибки площадь остаётся в сумме.
+ Память скана ограничена: части читаются кусками `_SCAN_CHUNK_BYTES`, expat держит только незаконченный токен;
+ общий объём — не больше лимита распакованного zip (проверен до скана).
+3. После чтения любого формата — сумма площадей `Sheet.merged`, которые индекс `Sheet` разложит по ячейкам (площадь
+ не больше `grid.MAX_INDEXED_AREA`): xls хранит диапазон в 8 байтах, тысячи средних диапазонов дали бы десятки
+ миллионов записей индекса. Диапазоны больше порога (merged на весь столбец) индекс не раскладывает — их площадь не
+ считаем, но считаем их число: `Sheet.merged_span` перебирает их линейно на каждое обращение, и 100 000 таких
+ диапазонов в xls — ≈ 4,4 мс на обращение; на лист — не больше `LARGE_MERGED_COUNT_LIMIT`.
+
+Защиты от XML-бомб (entity expansion, `defusedxml`) и от объёма самих ячеек в пределах лимита zip здесь нет: решение
+платформы (README, «Известные ограничения»). Скан раскрывает внутренние сущности так же, как openpyxl; от экспоненты
+«billion laughs» защищает сам expat ≥ 2.4 (ошибка разбора → `UnreadableError` для частей `*.xml`).
+"""
+
+import zipfile
+from collections.abc import Iterable
+from typing import IO
+from xml.parsers import expat
+
+from openpyxl.worksheet.cell_range import CellRange
+
+from src.parser.errors import UnreadableError, UnsupportedFormatError
+from src.parser.grid import MAX_INDEXED_AREA, Sheet
+
+__all__ = [
+ "LARGE_MERGED_COUNT_LIMIT",
+ "MERGED_AREA_LIMIT",
+ "ZIP_UNCOMPRESSED_LIMIT_FACTOR",
+ "check_merged_area",
+ "check_xlsx_merged_area",
+ "check_zip_uncompressed_size",
+ "merged_area_of_xml",
+]
+
+# Распакованный zip — не больше 10 × PARSE_MAX_BYTES; реальные книги распаковываются в 1,4–7,7 раза
+ZIP_UNCOMPRESSED_LIMIT_FACTOR = 10
+# Суммарная площадь объединённых ячеек книги (ячеек). Реальный максимум — 10 090 (xls, 3 444 диапазона), у xlsx —
+# 6 637; лимит ≈ 20× с запасом. openpyxl на 200 000 объединённых ячеек — ~0,6 с и ~60 МБ
+MERGED_AREA_LIMIT = 200_000
+# Число диапазонов листа площадью больше `MAX_INDEXED_AREA` (индекс их не раскладывает, поиск — перебором). В реальных
+# книгах — единицы; 1000 — ≈ 44 мкс на обращение к `Sheet.merged_span` (100 000 — ≈ 4,4 мс)
+LARGE_MERGED_COUNT_LIMIT = 1000
+_SCAN_CHUNK_BYTES = 64 * 1024
+_MERGE_LOCAL_NAME = "mergeCell"
+_REF_ATTRIBUTE = "ref" # без префикса: с namespace_separator expat отдаёт атрибут с префиксом как «uriимя»
+# Разделитель «пространство имён — локальное имя»: в XML-имени не встречается, локальное имя — после последнего
+_NS_SEPARATOR = " "
+_XML_SUFFIX = ".xml"
+_REF_IN_MESSAGE_CHARS = 40
+
+
+def _large_merged_limit_error(sheet: str, limit: int) -> UnsupportedFormatError:
+ return UnsupportedFormatError(
+ f"превышен лимит крупных объединённых диапазонов: на листе {sheet!r} больше {limit} диапазонов площадью больше "
+ f"{MAX_INDEXED_AREA} ячеек (LARGE_MERGED_COUNT_LIMIT): книга не открывается"
+ )
+
+
+def _merged_limit_error(limit: int) -> UnsupportedFormatError:
+ return UnsupportedFormatError(
+ f"превышен лимит объединённых ячеек: площадь объединённых диапазонов книги больше {limit} ячеек "
+ "(MERGED_AREA_LIMIT): книга не открывается"
+ )
+
+
+def check_zip_uncompressed_size(archive: zipfile.ZipFile, limit: int) -> None:
+ """Сумма объявленных несжатых размеров записей архива не больше `limit` байт."""
+ total = sum(info.file_size for info in archive.infolist())
+ if total > limit:
+ raise UnsupportedFormatError(
+ f"распакованный размер zip-архива {total} байт больше лимита {limit} байт "
+ f"({ZIP_UNCOMPRESSED_LIMIT_FACTOR} × PARSE_MAX_BYTES): книга не открывается"
+ )
+
+
+def _ref_area(ref: str | None) -> int:
+ """Площадь диапазона `ref` элемента mergeCell по правилам openpyxl (`CellRange`); не разбирается — книга не
+ читается (openpyxl упал бы на том же ref)."""
+ try:
+ if ref is None:
+ raise ValueError("нет атрибута ref")
+ span = CellRange(ref)
+ except (TypeError, ValueError) as exc:
+ shown = (ref or "")[:_REF_IN_MESSAGE_CHARS]
+ raise UnreadableError(f"диапазон объединения не читается: ") from exc
+ size: dict[str, int] = span.size
+ return size["rows"] * size["columns"]
+
+
+def merged_area_of_xml(
+ stream: IO[bytes], *, limit: int, chunk_size: int = _SCAN_CHUNK_BYTES, start: int = 0, strict: bool = True
+) -> int:
+ """Площадь `start` + диапазонов `mergeCell` потока XML; больше `limit` — `UnsupportedFormatError` сразу, не
+ дочитывая поток.
+
+ Поток читается кусками и подаётся expat с разбором пространств имён (правила — в docstring модуля). Битый XML:
+ `strict` — `UnreadableError`, иначе скан части заканчивается, найденная до ошибки площадь остаётся в сумме.
+ """
+ area = start
+
+ def on_start(name: str, attributes: dict[str, str]) -> None:
+ nonlocal area
+ if name.rpartition(_NS_SEPARATOR)[2] != _MERGE_LOCAL_NAME:
+ return
+ area += _ref_area(attributes.get(_REF_ATTRIBUTE))
+ if area > limit:
+ raise _merged_limit_error(limit)
+
+ parser = expat.ParserCreate(namespace_separator=_NS_SEPARATOR)
+ parser.StartElementHandler = on_start
+ try:
+ while chunk := stream.read(chunk_size):
+ parser.Parse(chunk, False)
+ parser.Parse(b"", True)
+ except expat.ExpatError as exc:
+ if strict:
+ raise UnreadableError(f"часть книги — не корректный XML: {exc}") from exc
+ return area
+
+
+def check_xlsx_merged_area(archive: zipfile.ZipFile, limit: int = MERGED_AREA_LIMIT) -> None:
+ """Суммарная площадь объединённых ячеек всех некаталожных частей xlsx — не больше `limit` (до вызова openpyxl).
+
+ Части `*.xml` сканируются строго (битый XML — `UnreadableError`), остальные — мягко (ошибка разбора лишь
+ заканчивает скан части)."""
+ area = 0
+ for info in sorted(archive.infolist(), key=lambda entry: entry.filename):
+ if info.is_dir():
+ continue
+ strict = info.filename.lower().endswith(_XML_SUFFIX)
+ with archive.open(info) as stream:
+ area = merged_area_of_xml(stream, limit=limit, start=area, strict=strict)
+
+
+def check_merged_area(
+ sheets: Iterable[Sheet], limit: int = MERGED_AREA_LIMIT, large_limit: int = LARGE_MERGED_COUNT_LIMIT
+) -> None:
+ """Суммарная площадь диапазонов `Sheet.merged` прочитанной книги, которые индекс `Sheet` разложит по ячейкам
+ (не больше `MAX_INDEXED_AREA`), — не больше `limit`; число остальных (крупных) диапазонов листа — не больше
+ `large_limit` (до построения индекса merged)."""
+ area = 0
+ for sheet in sheets:
+ large = 0
+ for span in sheet.merged:
+ if span.area > MAX_INDEXED_AREA:
+ # индекс его не раскладывает: поиск перебором `Sheet._large_merged` на каждое обращение
+ large += 1
+ if large > large_limit:
+ raise _large_merged_limit_error(sheet.name, large_limit)
+ continue
+ area += span.area
+ if area > limit:
+ raise _merged_limit_error(limit)
diff --git a/api/src/parser/readers/xls.py b/api/src/parser/readers/xls.py
new file mode 100644
index 0000000..f5d05d0
--- /dev/null
+++ b/api/src/parser/readers/xls.py
@@ -0,0 +1,68 @@
+"""Чтение XLS (BIFF8) через xlrd с `formatting_info`: merged, outline level, шрифт и отступ из XF-записей."""
+
+import io
+from typing import Any
+
+import xlrd
+
+from src.parser.grid import Cell, MergedRange, Sheet
+from src.parser.readers._common import make_cell
+
+__all__ = ["read_xls"]
+
+
+def read_xls(data: bytes) -> tuple[Sheet, ...]:
+ # logfile: xlrd пишет замечания о структуре BIFF в stdout — в выводе сервиса им не место
+ book = xlrd.open_workbook(file_contents=data, formatting_info=True, logfile=io.StringIO())
+ try:
+ return tuple(_sheet(book, sheet) for sheet in book.sheets())
+ finally:
+ book.release_resources()
+
+
+def _sheet(book: Any, sheet: Any) -> Sheet:
+ cells: dict[tuple[int, int], Cell] = {}
+ for rowx in range(sheet.nrows):
+ types = sheet.row_types(rowx)
+ values = sheet.row_values(rowx)
+ for colx, (ctype, raw) in enumerate(zip(types, values, strict=True)):
+ value = _value(book, ctype, raw)
+ if value is None:
+ continue
+ xf = book.xf_list[sheet.cell_xf_index(rowx, colx)]
+ cell = make_cell(
+ value,
+ bold=bool(book.font_list[xf.font_index].bold),
+ indent=int(xf.alignment.indent_level or 0),
+ )
+ if cell is not None:
+ cells[(rowx + 1, colx + 1)] = cell
+ # xlrd: (rlo, rhi, clo, chi), 0-based, полуоткрытые → 1-based включительно
+ merged = tuple(
+ MergedRange(top=rlo + 1, left=clo + 1, bottom=rhi, right=chi) for rlo, rhi, clo, chi in sheet.merged_cells
+ )
+ row_outline = {rowx + 1: int(info.outline_level) for rowx, info in sheet.rowinfo_map.items() if info.outline_level}
+ return Sheet(
+ name=str(sheet.name),
+ cells=cells,
+ merged=merged,
+ row_outline=row_outline,
+ n_rows=int(sheet.nrows),
+ n_cols=int(sheet.ncols),
+ )
+
+
+def _value(book: Any, ctype: int, raw: Any) -> object:
+ """Значение ячейки xlrd по её типу; пустые и BLANK → None."""
+ if ctype in (xlrd.XL_CELL_EMPTY, xlrd.XL_CELL_BLANK):
+ return None
+ if ctype == xlrd.XL_CELL_BOOLEAN:
+ return bool(raw)
+ if ctype == xlrd.XL_CELL_ERROR:
+ return xlrd.error_text_from_code.get(raw, f"#ERR{raw}")
+ if ctype == xlrd.XL_CELL_DATE:
+ try:
+ return xlrd.xldate_as_datetime(raw, book.datemode)
+ except (xlrd.xldate.XLDateError, ValueError, OverflowError):
+ return raw # серийный номер вне диапазона дат — остаётся числом, как в файле
+ return raw
diff --git a/api/src/parser/readers/xlsb.py b/api/src/parser/readers/xlsb.py
new file mode 100644
index 0000000..760fe58
--- /dev/null
+++ b/api/src/parser/readers/xlsb.py
@@ -0,0 +1,37 @@
+"""Чтение XLSB (BIFF12) через pyxlsb: только значения; outline level, merged и шрифт библиотека не отдаёт."""
+
+import io
+from typing import Any
+
+import pyxlsb
+
+from src.parser.grid import Cell, Sheet
+from src.parser.readers._common import make_cell
+
+__all__ = ["OUTLINE_UNAVAILABLE_WARNING", "read_xlsb"]
+
+OUTLINE_UNAVAILABLE_WARNING = "xlsb: outline level недоступен"
+
+
+def read_xlsb(data: bytes) -> tuple[Sheet, ...]:
+ with pyxlsb.open_workbook(io.BytesIO(data)) as book:
+ return tuple(_sheet(str(name), book) for name in book.sheets)
+
+
+def _sheet(name: str, book: Any) -> Sheet:
+ cells: dict[tuple[int, int], Cell] = {}
+ with book.get_sheet(name) as sheet:
+ for row in sheet.rows(sparse=True):
+ for source in row:
+ cell = make_cell(source.v)
+ if cell is not None:
+ cells[(source.r + 1, source.c + 1)] = cell
+ # Даты в xlsb — серийные числа без формата (pyxlsb не читает стили): остаются числами
+ return Sheet(
+ name=name,
+ cells=cells,
+ merged=(),
+ row_outline={},
+ n_rows=max((row for row, _ in cells), default=0),
+ n_cols=max((col for _, col in cells), default=0),
+ )
diff --git a/api/src/parser/readers/xlsx.py b/api/src/parser/readers/xlsx.py
new file mode 100644
index 0000000..efaf1cc
--- /dev/null
+++ b/api/src/parser/readers/xlsx.py
@@ -0,0 +1,44 @@
+"""Чтение XLSX через openpyxl (полное чтение: нужны merged, outline level и шрифт)."""
+
+import io
+from typing import Any
+
+import openpyxl
+
+from src.parser.grid import Cell, MergedRange, Sheet
+from src.parser.readers._common import make_cell
+
+__all__ = ["read_xlsx"]
+
+
+def read_xlsx(data: bytes) -> tuple[Sheet, ...]:
+ book = openpyxl.load_workbook(io.BytesIO(data), data_only=True, read_only=False)
+ try:
+ return tuple(_sheet(worksheet) for worksheet in book.worksheets)
+ finally:
+ book.close()
+
+
+def _sheet(ws: Any) -> Sheet:
+ cells: dict[tuple[int, int], Cell] = {}
+ # `_cells` — разреженное хранилище openpyxl (версия закреплена): iter_rows создал бы пустые ячейки на весь
+ # прямоугольник листа
+ for source in ws._cells.values():
+ cell = make_cell(source.value, bold=bool(source.font.bold), indent=int(source.alignment.indent or 0))
+ if cell is not None:
+ cells[(source.row, source.column)] = cell
+ merged = tuple(
+ MergedRange(top=span.min_row, left=span.min_col, bottom=span.max_row, right=span.max_col)
+ for span in ws.merged_cells.ranges
+ )
+ row_outline = {
+ row: int(dimension.outline_level) for row, dimension in ws.row_dimensions.items() if dimension.outline_level
+ }
+ return Sheet(
+ name=str(ws.title),
+ cells=cells,
+ merged=merged,
+ row_outline=row_outline,
+ n_rows=int(ws.max_row),
+ n_cols=int(ws.max_column),
+ )
diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py
new file mode 100644
index 0000000..a3c3788
--- /dev/null
+++ b/api/src/parser/rules.py
@@ -0,0 +1,244 @@
+"""Словари `rules/*.yaml` и загрузчик (design D-7): синонимы заголовков, группировок, периодов и типов данных.
+
+Словари — единственный носитель синонимов: код парсера только вызывает `Pattern.search` и не знает ни одного слова
+из ведомости. Новый синоним — строка в YAML.
+
+Правила нормализации (одни и те же для проверяемого текста и для самих паттернов в YAML):
+текст приводится `normalize_text` (нижний регистр, «ё» -> «е», дефисы/тире/неразрывные пробелы/переносы -> пробел,
+пробелы схлопнуты), а паттерн компилируется как есть с `re.IGNORECASE`. Поэтому паттерн, содержащий «ё», дефис или
+неразрывный пробел, никогда не сработал бы; загрузчик отвергает такие паттерны с указанием файла и ключа.
+"""
+
+import re
+from collections.abc import Iterable
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Literal, TypeVar, get_args
+
+import yaml
+from pydantic import BaseModel, ConfigDict, ValidationError, model_validator
+from pydantic_core import core_schema
+
+from src.contract.common import DataType
+
+__all__ = [
+ "DEFAULT_RULES_DIR",
+ "DataTypeRules",
+ "OsvGroupRules",
+ "OsvHeaderRules",
+ "Pattern",
+ "PeriodRule",
+ "PeriodRules",
+ "Rules",
+ "first_match",
+ "load_rules",
+ "normalize_text",
+]
+
+DEFAULT_RULES_DIR = Path(__file__).resolve().parents[2] / "rules"
+
+HEADERS_FILE = "osv_headers.yaml"
+GROUPS_FILE = "osv_groups.yaml"
+PERIODS_FILE = "periods.yaml"
+DATA_TYPES_FILE = "data_types.yaml"
+
+GroupType = Literal["ACCOUNT", "DEPARTMENT", "COUNTERPARTY", "CONTRACT", "OTHER"]
+SumGroup = Literal["opening", "turnover", "closing"]
+HeaderMarker = Literal["account", "period", "data_type", "currency", "org_params", "subkonto"]
+RowDataType = Literal["CURRENCY", "QUANTITY"]
+Quarter = Literal["Q1", "Q2", "Q3", "Q4"]
+
+_ISO_CODE_LENGTH = 3
+# Разделители, сводимые к одному пробелу: любые пробельные (включая NBSP и переносы строк), дефис-минус, дефисы и тире
+# U+2010–U+2015, знак минус U+2212
+_SEPARATORS = re.compile(r"[\s\-\u2010-\u2015\u2212]+")
+# Невидимые символы переноса: мягкий перенос, пробел нулевой ширины, BOM — удаляются, слово не разрывается
+_INVISIBLE = re.compile("[\u00ad\u200b\ufeff]")
+# Что нормализация убирает из текста: таких символов в паттерне быть не может
+_UNMATCHABLE_IN_PATTERN = re.compile(r"[ёЁ\s\-\u2010-\u2015\u2212\u00ad\u200b\ufeff]")
+
+Value = TypeVar("Value")
+
+
+def normalize_text(s: str) -> str:
+ """Нижний регистр, «ё» -> «е», пробельные символы и дефисы/тире -> один пробел, невидимые переносы удалены."""
+ lowered = _INVISIBLE.sub("", s).lower().replace("ё", "е")
+ return _SEPARATORS.sub(" ", lowered).strip()
+
+
+class Pattern:
+ """Регулярное выражение словаря: `search` применяется к `normalize_text(text)`, регистр не учитывается."""
+
+ __slots__ = ("_regex", "source")
+
+ def __init__(self, source: str, regex: re.Pattern[str]) -> None:
+ self.source = source
+ self._regex = regex
+
+ @classmethod
+ def compile(cls, source: str) -> "Pattern":
+ """Компиляция записи словаря. `ValueError` — пробел/«ё»/дефис в самом паттерне или неверная регулярка."""
+ if _UNMATCHABLE_IN_PATTERN.search(source.replace(" ", "")):
+ raise ValueError(
+ f"паттерн {source!r} не нормализован: «ё», дефисы, тире и переносы строк в паттерне не допускаются "
+ "(проверяемый текст нормализуется: «е» вместо «ё», пробел вместо дефиса); пишите `\\s`, `\\w`"
+ )
+ try:
+ regex = re.compile(source, re.IGNORECASE)
+ except re.error as exc:
+ raise ValueError(f"паттерн {source!r}: некорректное регулярное выражение ({exc})") from exc
+ return cls(source, regex)
+
+ def search(self, text: str) -> re.Match[str] | None:
+ """Первое совпадение в нормализованном тексте; позиции `Match` относятся к нормализованной строке."""
+ return self._regex.search(normalize_text(text))
+
+ def search_normalized(self, normalized: str) -> re.Match[str] | None:
+ """Как `search`, но текст уже приведён `normalize_text`: ячейка нормализуется один раз на все паттерны."""
+ return self._regex.search(normalized)
+
+ def __repr__(self) -> str:
+ return f"Pattern({self.source!r})"
+
+ @classmethod
+ def __get_pydantic_core_schema__(cls, source_type: Any, handler: Any) -> core_schema.CoreSchema:
+ return core_schema.no_info_after_validator_function(
+ cls.compile,
+ core_schema.str_schema(),
+ serialization=core_schema.plain_serializer_function_ser_schema(lambda pattern: pattern.source),
+ )
+
+
+def first_match(entries: Iterable[tuple[Pattern, Value]], text: str) -> Value | None:
+ """Значение первой записи словаря, паттерн которой совпал с текстом (порядок записей = приоритет)."""
+ for pattern, value in entries:
+ if pattern.search(text):
+ return value
+ return None
+
+
+class _Model(BaseModel):
+ # extra=forbid: опечатка в ключе словаря — ошибка загрузки, а не молча игнорируемый синоним
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+
+class OsvHeaderRules(_Model):
+ sheet_markers: list[Pattern]
+ statement_markers: list[Pattern]
+ groups: dict[SumGroup, list[Pattern]]
+ debit: list[Pattern]
+ credit: list[Pattern]
+ service_columns: dict[str, list[Pattern]]
+ total_row: list[Pattern]
+ header_markers: dict[HeaderMarker, list[Pattern]]
+
+ @model_validator(mode="after")
+ def _check_required_keys(self) -> "OsvHeaderRules":
+ for field_name, expected in (
+ ("groups", set(get_args(SumGroup))),
+ ("header_markers", set(get_args(HeaderMarker))),
+ ):
+ missing = expected - set(getattr(self, field_name))
+ if missing:
+ raise ValueError(f"{field_name}: нет обязательных ключей {sorted(missing)}")
+ if "inn" not in self.service_columns:
+ raise ValueError("service_columns: нет обязательного ключа 'inn'")
+ return self
+
+
+class OsvGroupRules(_Model):
+ mapping: list[tuple[Pattern, GroupType]]
+ default: GroupType = "OTHER"
+
+ def classify(self, text: str) -> GroupType:
+ """Тип группировки по названию из шапки; не совпавшее ни с одним паттерном — `default`."""
+ found = first_match(self.mapping, text)
+ return self.default if found is None else found
+
+
+class PeriodRule(_Model):
+ pattern: Pattern
+ quarter: Quarter
+ is_cumulative: bool
+
+
+class PeriodRules(_Model):
+ rules: list[PeriodRule]
+ year: Pattern
+ end: Pattern
+
+ def match(self, text: str) -> PeriodRule | None:
+ """Первое правило, паттерн которого совпал с текстом периода; порядок в YAML — от специфичных к общим."""
+ for rule in self.rules:
+ if rule.pattern.search(text):
+ return rule
+ return None
+
+
+class DataTypeRules(_Model):
+ header: list[tuple[Pattern, DataType]]
+ row: list[tuple[Pattern, RowDataType]]
+ currency_codes: list[str]
+ currency_aliases: dict[str, list[Pattern]]
+
+ @model_validator(mode="after")
+ def _check_currencies(self) -> "DataTypeRules":
+ bad = [code for code in self.currency_codes if len(code) != _ISO_CODE_LENGTH or not code.isupper()]
+ if bad:
+ raise ValueError(f"currency_codes: ожидаются буквенные коды ISO 4217 в верхнем регистре, получено {bad}")
+ unknown = sorted(set(self.currency_aliases) - set(self.currency_codes))
+ if unknown:
+ raise ValueError(f"currency_aliases: коды {unknown} не перечислены в currency_codes")
+ return self
+
+ def currency_code(self, text: str) -> str | None:
+ """Код ISO по тексту: сам код («usd», «RUB») или синоним из `currency_aliases` («руб.»); иначе `None`."""
+ bare = normalize_text(text).upper()
+ if bare in self.currency_codes:
+ return bare
+ for code, patterns in self.currency_aliases.items():
+ if any(pattern.search(text) for pattern in patterns):
+ return code
+ return None
+
+
+@dataclass(frozen=True)
+class Rules:
+ headers: OsvHeaderRules
+ groups: OsvGroupRules
+ periods: PeriodRules
+ data_types: DataTypeRules
+
+
+def load_rules(path: Path = DEFAULT_RULES_DIR) -> Rules:
+ """Читает четыре словаря каталога и валидирует их; любая проблема — `RuntimeError` с файлом и ключом."""
+ return Rules(
+ headers=_load(path, HEADERS_FILE, OsvHeaderRules),
+ groups=_load(path, GROUPS_FILE, OsvGroupRules),
+ periods=_load(path, PERIODS_FILE, PeriodRules),
+ data_types=_load(path, DATA_TYPES_FILE, DataTypeRules),
+ )
+
+
+ModelT = TypeVar("ModelT", bound=BaseModel)
+
+
+def _load(directory: Path, name: str, model: type[ModelT]) -> ModelT:
+ file = directory / name
+ try:
+ raw = yaml.safe_load(file.read_text(encoding="utf-8"))
+ except OSError as exc:
+ raise RuntimeError(f"словарь {name}: файл не прочитан ({file}): {exc.strerror or exc}") from exc
+ except yaml.YAMLError as exc:
+ reason = " ".join(str(exc).split())
+ raise RuntimeError(f"словарь {name}: некорректный YAML ({file}): {reason}") from exc
+ if not isinstance(raw, dict):
+ raise RuntimeError(f"словарь {name}: ожидается словарь верхнего уровня ({file})")
+ try:
+ return model.model_validate(raw)
+ except ValidationError as exc:
+ problems = [
+ f"словарь {name}: {'.'.join(str(part) for part in error['loc']) or '<корень>'}: {error['msg']}"
+ for error in exc.errors()
+ ]
+ raise RuntimeError("\n".join(problems)) from exc
diff --git a/api/tests/conftest.py b/api/tests/conftest.py
index 90c2304..d7a0b88 100644
--- a/api/tests/conftest.py
+++ b/api/tests/conftest.py
@@ -5,6 +5,13 @@ from collections.abc import Callable
from pathlib import Path
import pytest
+from fixtures_helpers import (
+ fixtures_skip_reason,
+ osv_fixture_files,
+ resolve_fixtures_dir,
+ synthetic_files,
+ synthetic_skip_reason,
+)
from src.core.config import Settings
@@ -20,6 +27,8 @@ _SETTINGS_ENV_PREFIXES = (
"WORKER_CONCURRENCY",
"PUBLISH_RETRIES",
"DONE_CACHE_SIZE",
+ "PARSE_MAX_BYTES",
+ "PARSE_TIMEOUT_SECONDS",
)
# Минимальное окружение spec «Старт с минимальным окружением»: MinIO, адрес NATS и DEBUG; без seed/jwt, БД и SMB
@@ -46,3 +55,52 @@ def minimal_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Callabl
return Settings(_env_file=env_file) # type: ignore[call-arg]
return build
+
+
+@pytest.fixture(scope="session")
+def synthetic_dir(fixtures_dir: Path) -> Path:
+ """`fixtures/osv/synthetic`; тест пропускается с причиной, если синтетики в каталоге нет."""
+ reason = synthetic_skip_reason()
+ if reason is not None:
+ pytest.skip(reason)
+ return fixtures_dir / "osv" / "synthetic"
+
+
+@pytest.fixture(scope="session")
+def fixtures_dir() -> Path:
+ """Каталог `../fixtures`; тест пропускается с причиной, если каталога нет."""
+ reason = fixtures_skip_reason()
+ if reason is not None:
+ pytest.skip(reason)
+ return resolve_fixtures_dir()
+
+
+def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
+ """Параметры `osv_file` (реальные ОСВ) и `synthetic_file` (синтетика); без каталога — один параметр со skip."""
+ sources = {
+ "osv_file": (osv_fixture_files, fixtures_skip_reason),
+ "synthetic_file": (synthetic_files, lambda: fixtures_skip_reason() or synthetic_skip_reason()),
+ }
+ for argname, (listing, skip_reason) in sources.items():
+ if argname not in metafunc.fixturenames:
+ continue
+ reason = skip_reason()
+ if reason is not None:
+ metafunc.parametrize(
+ argname, [pytest.param(None, marks=pytest.mark.skip(reason=reason))], ids=["нет-фикстур"]
+ )
+ continue
+ root = resolve_fixtures_dir()
+ files = listing(root)
+ metafunc.parametrize(argname, files, ids=[path.relative_to(root).as_posix() for path in files])
+
+
+def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
+ """Тесты с маркером `fixtures` пропускаются, если каталог фикстур недоступен."""
+ reason = fixtures_skip_reason()
+ if reason is None:
+ return
+ skip = pytest.mark.skip(reason=reason)
+ for item in items:
+ if item.get_closest_marker("fixtures"):
+ item.add_marker(skip)
diff --git a/api/tests/fixtures/real_osv.py b/api/tests/fixtures/real_osv.py
new file mode 100644
index 0000000..9bbbdd7
--- /dev/null
+++ b/api/tests/fixtures/real_osv.py
@@ -0,0 +1,69 @@
+"""Разбор реального файла ОСВ до классифицированных строк — общий конвейер fixtures-тестов иерархии и классификации.
+
+Тот же порядок, что у парсера: лист ОСВ → заголовок таблицы → шапка и схема группировок → строки → каскад иерархии →
+классификация. Результат кэшируется по пути: несколько тестов одного файла разбирают его один раз; `seconds` —
+время первого (полного) разбора, включая чтение книги.
+"""
+
+import time
+from dataclasses import dataclass
+from functools import cache
+from pathlib import Path
+from typing import cast
+
+from src.contract import FileType
+from src.contract.result import OsvGroup
+from src.parser.classify import ClassifiedRow, classify_rows, collect_body_rows
+from src.parser.deadline import Deadline
+from src.parser.detect import SheetKind, TableHeader, classify_sheet, find_table_header
+from src.parser.grid import Sheet
+from src.parser.header import DocHeader, build_group_schema, parse_doc_header
+from src.parser.hierarchy import BodyRow, Levels, resolve_levels
+from src.parser.readers import read_book
+from src.parser.rules import load_rules
+
+PARSE_TIMEOUT_SECONDS = 120
+
+
+@dataclass(frozen=True)
+class RealParse:
+ sheet: Sheet
+ header: TableHeader
+ doc: DocHeader
+ schema: list[OsvGroup]
+ body: list[BodyRow]
+ total_row: int | None
+ levels: Levels
+ rows: list[ClassifiedRow]
+ warnings: list[str]
+ seconds: float
+
+ @property
+ def data_rows(self) -> list[ClassifiedRow]:
+ """Строки данных без «Итого»."""
+ return [row for row in self.rows if row.group_type != "TOTAL"]
+
+ def by_text(self, text: str) -> list[ClassifiedRow]:
+ return [row for row in self.rows if row.body.text == text]
+
+ def row(self, number: int) -> ClassifiedRow:
+ return next(row for row in self.rows if row.body.row == number)
+
+
+@cache
+def parse_real(path: Path) -> RealParse:
+ rules = load_rules()
+ started = time.monotonic()
+ sheets, _ = read_book(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper()))
+ osv = [sheet for sheet in sheets if classify_sheet(sheet, rules) is SheetKind.OSV]
+ assert len(osv) == 1, path.name
+ sheet = osv[0]
+ deadline = Deadline(PARSE_TIMEOUT_SECONDS)
+ header = find_table_header(sheet, rules)
+ doc = parse_doc_header(sheet, header, rules)
+ schema, _ = build_group_schema(sheet, header, rules)
+ body, total_row, warnings = collect_body_rows(sheet, header, rules, deadline)
+ levels = resolve_levels(body, total_row, deadline)
+ rows = classify_rows(body, levels, schema, header, sheet, doc.data_type, total_row, rules)
+ seconds = time.monotonic() - started
+ return RealParse(sheet, header, doc, schema, body, total_row, levels, rows, warnings, seconds)
diff --git a/api/tests/fixtures/test_classify_real.py b/api/tests/fixtures/test_classify_real.py
new file mode 100644
index 0000000..85b79fc
--- /dev/null
+++ b/api/tests/fixtures/test_classify_real.py
@@ -0,0 +1,82 @@
+"""Классификация строк на реальных ОСВ из ../fixtures (spec «Классификация строк», R-13, T4.5).
+
+Свойства на всех файлах: ровно одна строка TOTAL — последняя, уровень 0 без родителя; строки без предупреждений
+о числах; подвал после «Итого» не попадает ни в строки, ни в `extra`. Конкретные строки — по сценариям спеки.
+"""
+
+from pathlib import Path
+
+import pytest
+from real_osv import parse_real
+
+from src.parser.detect import INN_LENGTHS
+
+SCHASTL_62_Q3 = Path("osv/schastl/2024-Q3/Оборотносальдовая ведомость по счету 62 за 3 квартал 2024 г..xls")
+RB_66_FY = Path("osv/rb/2024-FY/ОСВ_66 за 2024.xls")
+RB_60_Q1_2024 = Path("osv/rb/2024-Q1/сч 60_ 1 кв 2024 г.xlsx") # после «Итого» — числа правее таблицы
+
+
+@pytest.mark.fixtures
+def test_real_osv_has_exactly_one_total_as_last_root_row(osv_file: Path) -> None:
+ parsed = parse_real(osv_file)
+
+ totals = [row for row in parsed.rows if row.group_type == "TOTAL"]
+ assert len(totals) == 1
+ total = totals[0]
+ assert total is parsed.rows[-1]
+ assert (total.level, total.parent, total.body.row) == (0, None, parsed.total_row)
+ assert parsed.warnings == []
+
+
+@pytest.mark.fixtures
+def test_real_osv_rows_are_classified_into_schema(osv_file: Path) -> None:
+ parsed = parse_real(osv_file)
+
+ for row in parsed.data_rows:
+ assert row.data_type == parsed.doc.data_type, row # в фикстурах нет строк «Вал.»/«Кол.»
+ # Ни одной строки за схемой группировок: OTHER без имени группы допустим только у строк-показателей
+ if row.data_type not in ("CURRENCY", "QUANTITY"):
+ assert not (row.group_type == "OTHER" and row.group_name is None), row
+ if row.counterparty_inn is not None:
+ assert row.counterparty_inn.isdigit()
+ assert len(row.counterparty_inn) in INN_LENGTHS
+
+
+@pytest.mark.fixtures
+def test_schastl_62_department_counterparty_with_and_without_inn(fixtures_dir: Path) -> None:
+ """Spec «Подразделение, контрагент с ИНН, физлицо»."""
+ parsed = parse_real(fixtures_dir / SCHASTL_62_Q3)
+
+ assert {row.group_type for row in parsed.by_text("БП Аренда")} == {"DEPARTMENT"}
+ poultry = parsed.by_text("МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО")
+ assert poultry
+ for row in poultry:
+ assert row.group_type == "COUNTERPARTY"
+ assert row.counterparty_name == "МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО"
+ assert row.counterparty_inn is not None
+ assert row.counterparty_inn.isdigit()
+ assert len(row.counterparty_inn) == min(INN_LENGTHS)
+ (payroll,) = parsed.by_text("Ведомость в счет з/платы")
+ assert (payroll.group_type, payroll.counterparty_inn) == ("COUNTERPARTY", None)
+
+
+@pytest.mark.fixtures
+def test_rb_66_counterparty_inherits_subaccount(fixtures_dir: Path) -> None:
+ """Одноимённый контрагент под 66.03 и 66.04 различается счётом ветки (Review Focus 3)."""
+ parsed = parse_real(fixtures_dir / RB_66_FY)
+
+ yug = parsed.by_text("Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ")
+ assert [row.account_code_raw for row in yug] == ["66.03", "66.04"]
+ assert {row.group_type for row in yug} == {"COUNTERPARTY"}
+ assert yug[0].parent != yug[1].parent
+
+
+@pytest.mark.fixtures
+def test_rb_60_footer_after_total_is_not_data(fixtures_dir: Path) -> None:
+ """T4.5: числа правее таблицы после «Итого» не становятся строками и не попадают в `extra`."""
+ parsed = parse_real(fixtures_dir / RB_60_Q1_2024)
+
+ assert parsed.rows[-1].group_type == "TOTAL"
+ assert parsed.total_row is not None
+ assert parsed.sheet.n_rows > parsed.total_row # подвал есть
+ assert all(row.extra is None for row in parsed.rows)
diff --git a/api/tests/fixtures/test_detect_real.py b/api/tests/fixtures/test_detect_real.py
new file mode 100644
index 0000000..c97c7d8
--- /dev/null
+++ b/api/tests/fixtures/test_detect_real.py
@@ -0,0 +1,103 @@
+"""Детекторы на реальных ОСВ из ../fixtures: общие свойства на всех 66 файлах и две диагностики из спеки.
+
+Фикстуры — регрессия, не оракул: проверяются свойства, верные для любой ОСВ (лист ОСВ найден, шесть сумм, текст левее
+сумм, ИНН между текстом и суммами), и сценарий спеки «Плавающие колонки двух организаций». Буквы колонок сравниваются
+только здесь, в тестах, через `ColumnMap.as_diagnostics()` / `column_letter`.
+"""
+
+import time
+from pathlib import Path
+from typing import cast
+
+import pytest
+
+from src.contract import FileType
+from src.parser.detect import SheetKind, classify_sheet, find_table_header, first_data_row, is_header_repeat
+from src.parser.grid import Sheet, column_letter
+from src.parser.readers import read_book
+from src.parser.rules import Rules, load_rules
+
+DETECT_SECONDS_LIMIT = 5.0
+RB_60 = "osv/rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx"
+SCHASTL_62 = "osv/schastl/2024-Q3/Оборотносальдовая ведомость по счету 62 за 3 квартал 2024 г..xls"
+SCHASTL_62_LABEL_ROWS = 3 # Счет / Подразделение / Контрагенты (+ ИНН)
+SUM_COLUMNS = 6
+HEADER_MAX_ROWS = 4
+
+
+@pytest.fixture(scope="module")
+def rules() -> Rules:
+ return load_rules()
+
+
+def _sheets(path: Path) -> tuple[Sheet, ...]:
+ sheets, _ = read_book(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper()))
+ return sheets
+
+
+@pytest.mark.fixtures
+def test_every_real_osv_has_a_full_table_header(osv_file: Path, rules: Rules) -> None:
+ sheets = _sheets(osv_file)
+ started = time.perf_counter()
+
+ osv_sheets = [sheet for sheet in sheets if classify_sheet(sheet, rules) is SheetKind.OSV]
+ assert len(osv_sheets) == 1
+ sheet = osv_sheets[0]
+ header = find_table_header(sheet, rules)
+ data_start = first_data_row(sheet, header)
+ repeats = [row for row in range(data_start, sheet.n_rows + 1) if is_header_repeat(sheet, row, header, rules)]
+
+ assert time.perf_counter() - started < DETECT_SECONDS_LIMIT
+ sums = header.columns.indexes()
+ assert len(sums) == SUM_COLUMNS, header.columns.as_diagnostics()
+ assert len(set(sums.values())) == SUM_COLUMNS
+ assert header.columns.collapsed == frozenset()
+ columns = header.columns
+ for debit, credit in (
+ (columns.opening_debit, columns.opening_credit),
+ (columns.turnover_debit, columns.turnover_credit),
+ (columns.closing_debit, columns.closing_credit),
+ ):
+ assert debit is not None and credit is not None and debit < credit
+ assert header.text_col < min(sums.values())
+ assert header.right_edge >= max(sums.values())
+ if header.inn_col is not None:
+ assert header.text_col < header.inn_col < min(sums.values())
+ assert all(header.text_col < col <= header.right_edge for col in header.extra_cols)
+ assert header.first_row <= header.last_row < header.first_row + HEADER_MAX_ROWS
+ assert header.group_label_rows and header.group_label_rows[0] == header.first_row
+ assert data_start > header.last_row
+ # Первая строка данных — не метка заголовка: у неё есть сумма, либо это «Итого» пустой ведомости
+ first_values = {col for col, _ in sheet.row_cells(data_start)}
+ assert first_values & set(sums.values()) or any(
+ pattern.search(sheet.text_at(data_start, header.text_col)) for pattern in rules.headers.total_row
+ ), sheet.text_at(data_start, header.text_col)
+ assert repeats == []
+
+
+@pytest.mark.fixtures
+def test_rb_60_floating_columns(fixtures_dir: Path, rules: Rules) -> None:
+ sheet = _sheets(fixtures_dir / RB_60)[0]
+
+ header = find_table_header(sheet, rules)
+
+ diagnostics = header.columns.as_diagnostics()
+ assert [diagnostics[name] for name in header.columns.indexes()] == ["E", "H", "J", "L", "O", "Q"]
+ assert header.inn_col is None
+ assert column_letter(header.text_col) == "A"
+
+
+@pytest.mark.fixtures
+def test_schastl_62_floating_columns_inn_and_three_label_rows(fixtures_dir: Path, rules: Rules) -> None:
+ sheet = _sheets(fixtures_dir / SCHASTL_62)[0]
+
+ header = find_table_header(sheet, rules)
+
+ diagnostics = header.columns.as_diagnostics()
+ assert [diagnostics[name] for name in header.columns.indexes()] == ["G", "J", "K", "N", "Q", "S"]
+ assert header.inn_col is not None
+ assert column_letter(header.inn_col) == "E"
+ assert len(header.group_label_rows) == SCHASTL_62_LABEL_ROWS
+ # Мусор в T/U (spec «Мусор правее таблицы») — правее границы таблицы
+ assert column_letter(header.right_edge) == "S"
+ assert header.extra_cols == {}
diff --git a/api/tests/fixtures/test_golden.py b/api/tests/fixtures/test_golden.py
new file mode 100644
index 0000000..5ed3f9a
--- /dev/null
+++ b/api/tests/fixtures/test_golden.py
@@ -0,0 +1,41 @@
+"""Golden-снимки разбора (design D-10): результат парсера для каждого файла `fixtures/osv/**` и синтетики равен
+снимку в `fixtures/golden/expected_osv_parse/` (зеркало дерева `osv/`), без volatile-полей.
+
+Снимок — регрессия, а не оракул (оракулы — ручная сверка «Итого» и property-тест). Обновление — `make golden-update`,
+отдельным коммитом в репозиторий fixtures с просмотром diff.
+"""
+
+from pathlib import Path
+
+import pytest
+from golden_helpers import check_snapshot, golden_path
+
+from src.parser import Parser, get_parser
+
+pytestmark = pytest.mark.fixtures
+
+
+@pytest.fixture(scope="module")
+def parser() -> Parser:
+ return get_parser()
+
+
+def test_osv_file_matches_golden_snapshot(fixtures_dir: Path, osv_file: Path, parser: Parser) -> None:
+ check_snapshot(fixtures_dir, osv_file, parser)
+
+
+def test_synthetic_file_matches_golden_snapshot(fixtures_dir: Path, synthetic_file: Path, parser: Parser) -> None:
+ check_snapshot(fixtures_dir, synthetic_file, parser)
+
+
+def test_every_snapshot_has_a_source_file(fixtures_dir: Path) -> None:
+ """Лишний снимок (файл удалён или переименован) — ошибка: `make golden-update` его удаляет."""
+ from fixtures_helpers import osv_fixture_files, synthetic_files # noqa: PLC0415 - только для этого теста
+
+ expected = {
+ golden_path(fixtures_dir, file) for file in [*osv_fixture_files(fixtures_dir), *synthetic_files(fixtures_dir)]
+ }
+ directory = fixtures_dir / "golden" / "expected_osv_parse"
+ present = set(directory.rglob("*.json")) if directory.is_dir() else set()
+
+ assert sorted(path.relative_to(fixtures_dir).as_posix() for path in present - expected) == []
diff --git a/api/tests/fixtures/test_header_real.py b/api/tests/fixtures/test_header_real.py
new file mode 100644
index 0000000..519d350
--- /dev/null
+++ b/api/tests/fixtures/test_header_real.py
@@ -0,0 +1,114 @@
+"""Шапка и схема группировок на реальных ОСВ из ../fixtures: общие свойства на всех 66 файлах.
+
+Фикстуры — регрессия, не оракул: ожидаемые значения выводятся из имени файла и каталога одним общим правилом (без
+таблицы исключений), а парсер читает только содержимое. Правило имени файла — `expected_account`.
+"""
+
+import re
+from pathlib import Path
+from typing import cast
+
+import pytest
+from fixtures_helpers import osv_fixture_files
+
+from src.contract import FileType
+from src.parser.detect import SheetKind, classify_sheet, find_table_header
+from src.parser.grid import Sheet
+from src.parser.header import build_group_schema, parse_doc_header
+from src.parser.readers import read_book
+from src.parser.rules import Rules, load_rules
+
+# Первое число имени файла — счёт: «ОСВ_58 за 2024», «сч 01», «по счету 62 за 3 квартал»; «583» — три цифры —
+# счёт и субсчёт без точки («58», субсчёт «3» -> «58.03»)
+ACCOUNT_IN_NAME = re.compile(r"(?\d{4})-(?PFY|Q[1-4])$")
+EXPECTED_FILES = 66
+
+
+@pytest.fixture(scope="module")
+def rules() -> Rules:
+ return load_rules()
+
+
+def expected_account(path: Path) -> str:
+ """Код счёта из имени файла: первое число; три цифры без точки — счёт и субсчёт («583» -> «58.03»)."""
+ found = ACCOUNT_IN_NAME.search(path.stem)
+ assert found is not None, path.name
+ code = found.group(0)
+ if "." not in code and len(code) == SUBACCOUNT_WITHOUT_DOT:
+ return f"{code[:ACCOUNT_DIGITS]}.{code[ACCOUNT_DIGITS:].zfill(SUBACCOUNT_DIGITS)}"
+ return code
+
+
+def _osv_sheet(path: Path, rules: Rules) -> Sheet:
+ sheets, _ = read_book(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper()))
+ osv = [sheet for sheet in sheets if classify_sheet(sheet, rules) is SheetKind.OSV]
+ assert len(osv) == 1
+ return osv[0]
+
+
+@pytest.mark.fixtures
+def test_every_real_osv_header_and_group_schema(osv_file: Path, rules: Rules) -> None:
+ sheet = _osv_sheet(osv_file, rules)
+ table = find_table_header(sheet, rules)
+
+ doc = parse_doc_header(sheet, table, rules)
+ groups, variant = build_group_schema(sheet, table, rules)
+
+ assert doc.organization_name
+ assert doc.account_code_raw == expected_account(osv_file)
+ assert doc.period_text
+ assert doc.period_quarter is not None
+ assert doc.period_year is not None
+ assert doc.is_cumulative is not None
+ assert doc.data_type is not None # T3.9: значение («BU» и др.) не фиксируем — файлы не спецификация
+ assert doc.currency is not None
+ assert doc.header_variant == variant == 1
+ assert doc.warnings == ()
+ assert groups[0].type == "ACCOUNT"
+ assert len(groups) == len(table.group_label_rows)
+
+
+@pytest.mark.fixtures
+def test_real_osv_period_matches_its_directory(osv_file: Path, rules: Rules) -> None:
+ sheet = _osv_sheet(osv_file, rules)
+ doc = parse_doc_header(sheet, find_table_header(sheet, rules), rules)
+
+ directory = PERIOD_DIR.fullmatch(osv_file.parent.name)
+ assert directory is not None, osv_file.parent.name
+ year, kind = directory.group("year"), directory.group("kind")
+ assert doc.period_year == int(year)
+ if kind == "FY":
+ assert (doc.period_quarter, doc.is_cumulative) == ("Q4", True)
+ else:
+ assert doc.period_quarter == kind
+ if kind == "Q3":
+ assert doc.is_cumulative is False # ASSUMPTION(OQ-02): квартальная ведомость, не нарастающая
+
+
+@pytest.mark.fixtures
+def test_group_schemas_of_known_files(fixtures_dir: Path, rules: Rules) -> None:
+ schastl_62 = fixtures_dir / "osv/schastl/2024-Q3/Оборотносальдовая ведомость по счету 62 за 3 квартал 2024 г..xls"
+ sheet = _osv_sheet(schastl_62, rules)
+ groups, variant = build_group_schema(sheet, find_table_header(sheet, rules), rules)
+
+ assert variant == 1
+ assert [(g.type, g.has_inn) for g in groups] == [("ACCOUNT", None), ("DEPARTMENT", None), ("COUNTERPARTY", True)]
+ doc = parse_doc_header(sheet, find_table_header(sheet, rules), rules)
+ assert doc.period_text == "3 квартал 2024 г."
+
+
+@pytest.mark.fixtures
+def test_all_66_real_files_are_covered(fixtures_dir: Path) -> None:
+ assert len(osv_fixture_files(fixtures_dir)) == EXPECTED_FILES
+
+
+def test_expected_account_rule() -> None:
+ assert expected_account(Path("сч 583_3 кв 2024.xlsx")) == "58.03"
+ assert expected_account(Path("ОСВ_01_ОС по субсчетам.xlsx")) == "01"
+ assert expected_account(Path("ОСВ_58 за 2024.xls")) == "58"
+ assert expected_account(Path("сч 66_3 кв 2024.xlsx")) == "66"
+ assert expected_account(Path("отчет 2024 сч 60.xlsx")) == "60" # четыре цифры — не счёт: граница по цифрам
diff --git a/api/tests/fixtures/test_hierarchy_real.py b/api/tests/fixtures/test_hierarchy_real.py
new file mode 100644
index 0000000..349ad71
--- /dev/null
+++ b/api/tests/fixtures/test_hierarchy_real.py
@@ -0,0 +1,44 @@
+"""Иерархия строк на реальных ОСВ из ../fixtures (ruling R-2, R-13; spec «Иерархия строк — каскад стратегий»).
+
+Свойства без таблицы файлов: дерево непротиворечиво, «Итого» — уровень 0; у файла со строками данных — `outline`
+(у всех фикстур есть группировки Excel), у пустой ОСВ (только «Итого») — `trivial`. Разбор файла укладывается в 5 с.
+"""
+
+from pathlib import Path
+
+import pytest
+from real_osv import parse_real
+
+from src.parser.hierarchy import TRIVIAL_STRATEGY, is_consistent
+
+MAX_SECONDS_PER_FILE = 5
+RB_66_FY = Path("osv/rb/2024-FY/ОСВ_66 за 2024.xls")
+YUG_FINANS = "Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ"
+COUNTERPARTY_LEVEL = 2 # счёт (0) → субсчёт (1) → контрагент (2)
+
+
+@pytest.mark.fixtures
+def test_real_osv_hierarchy(osv_file: Path) -> None:
+ parsed = parse_real(osv_file)
+
+ assert is_consistent(parsed.body, parsed.levels.by_row, parsed.total_row)
+ assert parsed.total_row is not None
+ assert parsed.levels.by_row[parsed.total_row] == 0
+ assert parsed.levels.parent[parsed.total_row] is None
+ expected = "outline" if parsed.data_rows else TRIVIAL_STRATEGY
+ assert parsed.levels.strategy == expected
+ assert parsed.seconds < MAX_SECONDS_PER_FILE
+
+
+@pytest.mark.fixtures
+def test_rb_66_fy_outline_tree(fixtures_dir: Path) -> None:
+ """Spec «Outline level (реальные файлы)»: 66 (0) → 66.01 (1) → контрагент (2); «Юг-Финанс» — под 66.03."""
+ parsed = parse_real(fixtures_dir / RB_66_FY)
+
+ assert parsed.levels.strategy == "outline"
+ account, subaccount, counterparty = parsed.rows[:3]
+ assert [(account.body.text, account.level), (subaccount.body.text, subaccount.level)] == [("66", 0), ("66.01", 1)]
+ assert (counterparty.level, counterparty.parent) == (COUNTERPARTY_LEVEL, subaccount.body.row)
+ first = parsed.by_text(YUG_FINANS)[0]
+ assert first.parent is not None
+ assert parsed.row(first.parent).body.text == "66.03"
diff --git a/api/tests/fixtures/test_osv_property.py b/api/tests/fixtures/test_osv_property.py
new file mode 100644
index 0000000..dac1b3f
--- /dev/null
+++ b/api/tests/fixtures/test_osv_property.py
@@ -0,0 +1,54 @@
+"""Property-тест парсера на всех реальных ОСВ `../fixtures/osv/**` (design D-10; spec «Золотые снимки и
+property-тесты», «Контрольные суммы определяют статус», «Пустая ОСВ», «Форматы, лимиты, время»).
+
+Инварианты проверяются независимо от снимков: статус `done`, один документ, контрольные суммы §1.4 сошлись, корень =
+счёт шапки, период распознан, разбор быстрее 5 с, результат валиден по контракту v1.1. Пустая ОСВ (R-13) определяется
+свойством — в `rows` только TOTAL, — а не списком файлов.
+"""
+
+import time
+from pathlib import Path
+from typing import cast
+
+import pytest
+from result_contract import assert_valid_result
+
+from src.contract import FileType, OsvDocument, ResultStatus
+from src.parser import get_parser
+
+MAX_PARSE_SECONDS = 5
+
+
+@pytest.mark.fixtures
+def test_real_osv_parses_done_with_invariants(osv_file: Path) -> None:
+ parser = get_parser()
+ data = osv_file.read_bytes()
+
+ started = time.monotonic()
+ outcome = parser.parse(data, cast(FileType, osv_file.suffix.lstrip(".").upper()))
+ seconds = time.monotonic() - started
+
+ assert outcome.error is None, outcome.error
+ assert outcome.status is ResultStatus.DONE, outcome.metrics
+ (document,) = outcome.documents
+ assert isinstance(document, OsvDocument)
+ assert document.locator == "sheet:Лист_1" # spec «Один лист — одна ОСВ»: имя листа берётся из файла, не ищется
+ checks = document.checks
+ assert checks is not None
+ assert checks.total_row_matches_root is True
+ assert checks.balance_identity_violations == []
+ assert checks.parent_sum_violations == []
+ assert document.header.period_quarter is not None
+ data_rows = [row for row in document.rows if row.group_type != "TOTAL"]
+ assert outcome.metrics is not None
+ assert outcome.metrics.rows == len(data_rows)
+ if data_rows:
+ assert outcome.metrics.rows > 0
+ assert document.header.account_code_raw == document.rows[0].account_code_raw
+ else:
+ # Пустая ОСВ (R-13): только «Итого»
+ assert [row.group_type for row in document.rows] == ["TOTAL"]
+ assert outcome.metrics.warnings is not None
+ assert "нет строк данных" in outcome.metrics.warnings
+ assert seconds < MAX_PARSE_SECONDS, f"{osv_file.name}: {seconds:.2f} с"
+ assert_valid_result(outcome)
diff --git a/api/tests/fixtures/test_readers_real.py b/api/tests/fixtures/test_readers_real.py
new file mode 100644
index 0000000..e65f0c5
--- /dev/null
+++ b/api/tests/fixtures/test_readers_real.py
@@ -0,0 +1,49 @@
+"""Читатели на реальных ОСВ из ../fixtures: все 66 файлов читаются быстро, outline и merged на месте."""
+
+import time
+from pathlib import Path
+from typing import cast
+
+import pytest
+
+from src.contract import FileType
+from src.parser.readers import read_book
+
+READ_SECONDS_LIMIT = 5.0
+NAMED_XLS = "osv/schastl/2024-Q3/Оборотносальдовая ведомость по счету 62 за 3 квартал 2024 г..xls"
+# Строка 15 (1-based) — контрагент «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» под 62 → 62.01 → «БП Аренда»
+NAMED_XLS_ROW = 15
+NAMED_XLS_OUTLINE = 3
+
+
+@pytest.mark.fixtures
+def test_real_file_is_read_quickly_with_outline_and_merged(osv_file: Path) -> None:
+ data = osv_file.read_bytes()
+ started = time.perf_counter()
+
+ sheets, warnings = read_book(data, cast(FileType, osv_file.suffix.lstrip(".").upper()))
+
+ assert time.perf_counter() - started < READ_SECONDS_LIMIT
+ assert warnings == []
+ assert sheets
+ assert any(sheet.row_outline for sheet in sheets)
+ assert all(level >= 1 for sheet in sheets for level in sheet.row_outline.values())
+ assert any(sheet.merged for sheet in sheets)
+ assert any(sheet.cells for sheet in sheets)
+
+
+@pytest.mark.fixtures
+def test_named_xls_outline_level(fixtures_dir: Path) -> None:
+ sheets, _ = read_book((fixtures_dir / NAMED_XLS).read_bytes(), "XLS")
+
+ sheet = sheets[0]
+ assert sheet.cell(NAMED_XLS_ROW, 1).text == "МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО"
+ assert sheet.outline(NAMED_XLS_ROW) == NAMED_XLS_OUTLINE
+
+
+@pytest.mark.fixtures
+def test_biff8_content_with_xlsx_file_type_is_read_as_xls(fixtures_dir: Path) -> None:
+ sheets, warnings = read_book((fixtures_dir / NAMED_XLS).read_bytes(), "XLSX")
+
+ assert warnings == ["file_type=XLSX, но содержимое BIFF8: прочитано как XLS"]
+ assert sheets[0].outline(NAMED_XLS_ROW) == NAMED_XLS_OUTLINE
diff --git a/api/tests/fixtures/test_rules_coverage.py b/api/tests/fixtures/test_rules_coverage.py
new file mode 100644
index 0000000..1775259
--- /dev/null
+++ b/api/tests/fixtures/test_rules_coverage.py
@@ -0,0 +1,63 @@
+"""Покрытие словарей `rules/*.yaml` (design D-10, ruling R-22): каждая запись распознавания срабатывает хотя бы раз на
+реальных ОСВ и синтетике; мёртвый синоним — повод удалить его или добавить файл синтетики `s14_*`.
+
+Запись — один `Pattern` словаря (`sheet_markers`, `statement_markers`, `groups`, `debit`, `credit`, `service_columns`,
+`total_row`, `header_markers`, `mapping`, правила периодов, `year`, `end`, `header`/`row` типов данных,
+`currency_aliases`). Перечень кодов `currency_codes` — список значений, не синонимы: в покрытие не входит. Записи
+находятся обходом моделей `Rules`, поэтому новый паттерн в любом словаре попадает под проверку автоматически.
+
+«Срабатывание» — `search`/`search_normalized` вернул совпадение. Счётчик — обёртка вокруг методов `Pattern` в тесте,
+боевой код не меняется.
+"""
+
+from pathlib import Path
+
+import pytest
+from fixtures_helpers import osv_fixture_files, synthetic_files
+from rules_coverage import DICTIONARIES, MatchCounter, iter_patterns, uncovered
+
+from src.parser import get_parser
+from src.parser.osv import OsvParser
+from src.parser.rules import DEFAULT_RULES_DIR, Rules, load_rules
+
+pytestmark = pytest.mark.fixtures
+
+
+@pytest.fixture(scope="module")
+def coverage(fixtures_dir: Path) -> tuple[Rules, MatchCounter, int]:
+ monkeypatch = pytest.MonkeyPatch()
+ try:
+ counter = MatchCounter()
+ counter.install(monkeypatch)
+ rules = load_rules(DEFAULT_RULES_DIR)
+ base = get_parser()
+ assert isinstance(base, OsvParser)
+ parser = OsvParser(rules, max_bytes=base.max_bytes, timeout_seconds=base.timeout_seconds)
+ files = [*osv_fixture_files(fixtures_dir), *synthetic_files(fixtures_dir)]
+ for file in files:
+ parser.parse(file.read_bytes(), file.suffix.lstrip(".").upper()) # type: ignore[arg-type]
+ return rules, counter, len(files)
+ finally:
+ monkeypatch.undo()
+
+
+def test_every_dictionary_entry_fires_on_fixtures_and_synthetic(coverage: tuple[Rules, MatchCounter, int]) -> None:
+ rules, counter, files = coverage
+
+ dead = uncovered(rules, counter)
+
+ assert dead == [], (
+ f"записи словарей, не сработавшие ни на одном из {files} файлов (удалите синоним или добавьте синтетику "
+ "s14_*):\n" + "\n".join(dead)
+ )
+
+
+def test_coverage_sees_every_dictionary_entry_and_all_files(coverage: tuple[Rules, MatchCounter, int]) -> None:
+ """Счётчик не пуст и обход видит записи всех четырёх словарей (защита от «покрытия» на пустом месте)."""
+ rules, counter, files = coverage
+ entries = list(iter_patterns(rules))
+
+ assert {file_name for file_name, _, _ in entries} == set(DICTIONARIES.values())
+ assert sum(counter.hits.values()) > len(entries)
+ assert files >= 66 # noqa: PLR2004 - реальные ОСВ из fixtures
+ assert not any(path.startswith("currency_codes") for _, path, _ in entries)
diff --git a/api/tests/fixtures/test_rules_real.py b/api/tests/fixtures/test_rules_real.py
new file mode 100644
index 0000000..d9bfc1f
--- /dev/null
+++ b/api/tests/fixtures/test_rules_real.py
@@ -0,0 +1,64 @@
+"""Словари на реальных ОСВ из ../fixtures: формулировки шапки, блоков сумм, группировок и периодов покрыты.
+
+Проверяются сами формулировки файлов (текст ячеек шапки), а не позиции: детекторы Task 4–5 строятся поверх этих же
+словарей. Фикстуры — регрессия формулировок, не оракул формата.
+"""
+
+from pathlib import Path
+from typing import cast
+
+import pytest
+
+from src.contract import FileType
+from src.parser.grid import Sheet
+from src.parser.readers import read_book
+from src.parser.rules import Pattern, Rules, first_match, load_rules
+
+HEAD_ROWS = 30 # шапка и заголовок таблицы — в начале листа
+
+
+@pytest.fixture(scope="module")
+def rules() -> Rules:
+ return load_rules()
+
+
+def _head_texts(sheet: Sheet) -> list[str]:
+ last = min(sheet.n_rows, HEAD_ROWS)
+ return [cell.text for row in range(1, last + 1) for _, cell in sheet.row_cells(row) if cell.kind == "text"]
+
+
+def _after_colon(text: str) -> str:
+ return text.split(":", 1)[1] if ":" in text else text
+
+
+@pytest.mark.fixtures
+def test_real_osv_phrases_are_covered_by_dictionaries(osv_file: Path, rules: Rules) -> None:
+ sheets, _ = read_book(osv_file.read_bytes(), cast(FileType, osv_file.suffix.lstrip(".").upper()))
+ texts = [text for sheet in sheets for text in _head_texts(sheet)]
+ headers = rules.headers
+
+ def any_cell(patterns: list[Pattern]) -> bool:
+ return any(pattern.search(text) for text in texts for pattern in patterns)
+
+ assert any_cell(headers.sheet_markers), "нет заголовка ведомости"
+ assert not any_cell(headers.statement_markers), "ОСВ не должна выглядеть как форма ББ/ОФР"
+ for group in headers.groups:
+ assert any_cell(headers.groups[group]), f"нет блока сумм {group}"
+ assert any_cell(headers.debit)
+ assert any_cell(headers.credit)
+ assert any_cell(headers.header_markers["org_params"])
+ assert any_cell(headers.header_markers["data_type"])
+
+ title = next(text for text in texts if any(p.search(text) for p in headers.sheet_markers))
+ account = next(m for p in headers.header_markers["account"] if (m := p.search(title)))
+ assert account.string[account.end() :].strip()[:1].isdigit()
+ period = next(m for p in headers.header_markers["period"] if (m := p.search(title)))
+ period_text = period.string[period.end() :]
+ assert rules.periods.match(period_text) is not None, title
+ assert rules.periods.year.search(period_text) is not None, title
+
+ data_type_line = next(t for t in texts if any(p.search(t) for p in headers.header_markers["data_type"]))
+ # T3.9: реальные файлы не спецификация — не фиксируем «BU» и «RUB», проверяем, что словарь значение опознал
+ assert first_match(rules.data_types.header, _after_colon(data_type_line)) is not None
+ currency_line = next(t for t in texts if any(p.search(t) for p in headers.header_markers["currency"]))
+ assert rules.data_types.currency_code(_after_colon(currency_line)) is not None
diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py
new file mode 100644
index 0000000..007db5b
--- /dev/null
+++ b/api/tests/fixtures_helpers.py
@@ -0,0 +1,61 @@
+"""Доступ к реальным данным из `../fixtures` (отдельный репозиторий, внутренний доступ)."""
+
+import os
+from pathlib import Path
+
+FIXTURES_ENV = "ARM_KA_FIXTURES"
+OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"})
+_SYNTHETIC = "synthetic"
+# Не фикстуры: скрытые файлы и каталоги («.DS_Store», «.cache/») и lock-файлы Excel («~$книга.xlsx», пока она открыта)
+_IGNORED_PREFIXES = (".", "~$")
+
+
+def _is_workbook(path: Path, base: Path) -> bool:
+ """Файл книги Excel под `base`: расширение xls/xlsx/xlsb, ни файл, ни каталоги на пути — не скрытые и не lock."""
+ if not path.is_file() or path.suffix.lower() not in OSV_EXTENSIONS:
+ return False
+ return not any(part.startswith(_IGNORED_PREFIXES) for part in path.relative_to(base).parts)
+
+
+def resolve_fixtures_dir() -> Path:
+ """Каталог фикстур: `ARM_KA_FIXTURES` или соседний с репозиторием `fixtures/` (workspace)."""
+ override = os.environ.get(FIXTURES_ENV)
+ if override:
+ return Path(override)
+ return Path(__file__).resolve().parents[3] / "fixtures"
+
+
+def fixtures_skip_reason() -> str | None:
+ """Причина пропуска тестов на фикстурах (None — каталог доступен). Текст называет переменную и путь (OQ-81)."""
+ root = resolve_fixtures_dir()
+ if (root / "osv").is_dir():
+ return None
+ return f"каталог фикстур не найден: {root} (задайте {FIXTURES_ENV}=<путь к репозиторию fixtures>)"
+
+
+def synthetic_skip_reason() -> str | None:
+ """Причина пропуска тестов на синтетике: каталог fixtures есть, но `osv/synthetic` отсутствует или пуст
+ (например, fixtures на main до слияния ветки с синтетикой). `None` — файлы есть или нет самих fixtures (тогда
+ причину даёт `fixtures_skip_reason`)."""
+ root = resolve_fixtures_dir()
+ if not (root / "osv").is_dir() or synthetic_files(root):
+ return None
+ return f"нет синтетики: {root / 'osv' / _SYNTHETIC} отсутствует или пуст (сгенерируйте: см. README fixtures)"
+
+
+def osv_fixture_files(root: Path) -> list[Path]:
+ """Все `*.xls/*.xlsx/*.xlsb` под `root/osv`, кроме каталога synthetic, скрытых и lock-файлов; отсортированы."""
+ osv = root / "osv"
+ if not osv.is_dir():
+ return []
+ synthetic = osv / _SYNTHETIC
+ return sorted(path for path in osv.rglob("*") if _is_workbook(path, osv) and synthetic not in path.parents)
+
+
+def synthetic_files(root: Path) -> list[Path]:
+ """Файлы Excel `root/osv/synthetic/*.xls|xlsx|xlsb` (скрытые, lock-файлы `~$…` и пояснения вроде `README.md` не
+ считаются); отсортированы."""
+ directory = root / "osv" / _SYNTHETIC
+ if not directory.is_dir():
+ return []
+ return sorted(path for path in directory.iterdir() if _is_workbook(path, directory))
diff --git a/api/tests/golden_helpers.py b/api/tests/golden_helpers.py
new file mode 100644
index 0000000..2d48721
--- /dev/null
+++ b/api/tests/golden_helpers.py
@@ -0,0 +1,190 @@
+"""Golden-снимки разбора ОСВ (design D-10): результат парсера в `fixtures/golden/expected_osv_parse/`.
+
+Снимок — регрессия, а не оракул: он фиксирует то, что парсер выдаёт сейчас. Корректность проверяют ручная сверка
+«Итого» трёх файлов (отчёт задачи) и property-тест `tests/fixtures/test_osv_property.py`.
+
+Результат — тот же, что печатает CLI `src.parser.cli` (`core.worker.build_result` → `to_wire`), без полей, меняющихся
+от запуска к запуску (`VOLATILE`). Порядок ключей и `warnings` не трогается: он значим для diff.
+"""
+
+import copy
+import difflib
+import json
+from collections.abc import Iterable
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from src.contract import ResultStatus, ServiceInfo
+from src.core.config import Settings
+from src.core.worker import build_result
+from src.parser import Parser
+from src.parser.cli import STUB_ID, _error_payload, _file_type
+
+__all__ = [
+ "MISSING",
+ "SNAPSHOT_DIR",
+ "SyncReport",
+ "VOLATILE",
+ "check_snapshot",
+ "golden_path",
+ "normalize_result",
+ "render",
+ "snapshot_text",
+ "sync_snapshots",
+ "unified_diff",
+]
+
+SNAPSHOT_DIR = "expected_osv_parse"
+GOLDEN_DIR = "golden"
+OSV_DIR = "osv"
+SYNTHETIC_DIR = "synthetic"
+REAL_PART = "real"
+JSON_INDENT = 2
+# Поля, которые меняются от запуска к запуску: идентификаторы заглушек, версия сервиса, фактическая длительность
+VOLATILE: tuple[str | tuple[str, str], ...] = ("job_id", "file_id", ("service", "version"), ("metrics", "duration_ms"))
+MISSING = "нет снимка: выполните make golden-update ({path})"
+MAX_DIFF_CHARS = 6000
+
+
+def normalize_result(result: dict[str, Any]) -> dict[str, Any]:
+ """Копия результата без `VOLATILE`-полей; порядок остальных ключей и элементов списков сохраняется."""
+ normalized = copy.deepcopy(result)
+ for volatile in VOLATILE:
+ if isinstance(volatile, str):
+ normalized.pop(volatile, None)
+ else:
+ parent, key = volatile
+ inner = normalized.get(parent)
+ if isinstance(inner, dict):
+ inner.pop(key, None)
+ return normalized
+
+
+def golden_path(fixtures_root: Path, file: Path) -> Path:
+ """`golden/expected_osv_parse/<путь файла относительно osv/ без расширения>.json` (зеркало дерева `osv/`)."""
+ relative = file.relative_to(fixtures_root / OSV_DIR)
+ return fixtures_root / GOLDEN_DIR / SNAPSHOT_DIR / relative.parent / f"{relative.stem}.json"
+
+
+def render(parser: Parser, file: Path) -> dict[str, Any]:
+ """Результат разбора файла, как его выдаёт CLI: `to_wire` с фиксированными `job_id`/`file_id`, затем нормализация.
+
+ Ошибка разбора выглядит как в CLI — `{"status": "error", "error": {...}}`; файл с неизвестным расширением — ошибка
+ использования (`ValueError`).
+ """
+ file_type = _file_type(file, None)
+ if file_type is None:
+ raise ValueError(f"неизвестное расширение файла {file.suffix!r}: {file}")
+ outcome = parser.parse(file.read_bytes(), file_type)
+ if outcome.status is ResultStatus.ERROR:
+ return normalize_result(_error_payload(outcome))
+ service = ServiceInfo.model_validate(
+ {"name": Settings.model_fields["SERVICE_NAME"].default, "version": Settings.model_fields["VERSION"].default}
+ )
+ result = build_result(outcome, job_id=STUB_ID, file_id=STUB_ID, service=service, metrics=outcome.metrics)
+ return normalize_result(result.to_wire())
+
+
+def snapshot_text(normalized: dict[str, Any]) -> str:
+ """Текст снимка: как вывод CLI — без экранирования кириллицы, отступ 2, порядок ключей модели, «\\n» в конце."""
+ return json.dumps(normalized, ensure_ascii=False, indent=JSON_INDENT) + "\n"
+
+
+def unified_diff(stored: str, current: str, *, name: str) -> str:
+ """Unified diff «снимок в fixtures → разбор сейчас»; пустая строка, если тексты равны."""
+ return "".join(
+ difflib.unified_diff(
+ stored.splitlines(keepends=True),
+ current.splitlines(keepends=True),
+ fromfile=f"{name} (в fixtures)",
+ tofile=f"{name} (разбор сейчас)",
+ )
+ )
+
+
+@dataclass
+class SyncReport:
+ """Итог сверки/обновления снимков: пути относительно корня fixtures."""
+
+ created: list[Path] = field(default_factory=list)
+ changed: list[Path] = field(default_factory=list)
+ orphans: list[Path] = field(default_factory=list)
+ protected: list[Path] = field(default_factory=list) # снимки без исходного каталога: не удаляются (T12.3)
+ unchanged: int = 0
+ diffs: list[str] = field(default_factory=list)
+
+ @property
+ def differs(self) -> bool:
+ return bool(self.created or self.changed or self.orphans)
+
+
+def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, *, write: bool) -> SyncReport:
+ """Сверяет снимки `files` с текущим разбором; при `write` — перезаписывает изменившиеся и удаляет лишние.
+
+ Лишний снимок — файл в `golden/expected_osv_parse/`, которому не соответствует ни один файл из `files`.
+ Снимки делятся на части по исходному каталогу: `synthetic/` (синтетика `osv/synthetic`) и остальные (реальные
+ файлы `osv/`). Если в `files` нет ни одного файла части (каталог отсутствует или пуст — например, fixtures без
+ синтетики), её снимки не лишние: они попадают в `report.protected` и не удаляются (T12.3).
+ Для изменившегося снимка в `report.diffs` — unified diff; для нового — весь текст как добавленный.
+ """
+ report = SyncReport()
+ expected: set[Path] = set()
+ files = list(files)
+ present_parts = {_source_part(file.relative_to(fixtures_root / OSV_DIR)) for file in files}
+ for file in files:
+ target = golden_path(fixtures_root, file)
+ expected.add(target)
+ current = snapshot_text(render(parser, file))
+ name = target.relative_to(fixtures_root).as_posix()
+ if not target.is_file():
+ report.created.append(target.relative_to(fixtures_root))
+ report.diffs.append(unified_diff("", current, name=name))
+ else:
+ stored = target.read_text(encoding="utf-8")
+ if stored == current:
+ report.unchanged += 1
+ continue
+ report.changed.append(target.relative_to(fixtures_root))
+ report.diffs.append(unified_diff(stored, current, name=name))
+ if write:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(current, encoding="utf-8")
+ snapshots = fixtures_root / GOLDEN_DIR / SNAPSHOT_DIR
+ for stale in sorted(snapshots.rglob("*.json")) if snapshots.is_dir() else []:
+ if stale in expected:
+ continue
+ if _source_part(stale.relative_to(snapshots)) not in present_parts:
+ report.protected.append(stale.relative_to(fixtures_root))
+ continue
+ report.orphans.append(stale.relative_to(fixtures_root))
+ if write:
+ stale.unlink()
+ return report
+
+
+def _source_part(relative: Path) -> str:
+ """Часть дерева по пути относительно `osv/` (или каталога снимков): `synthetic` или `real`."""
+ return SYNTHETIC_DIR if relative.parts and relative.parts[0] == SYNTHETIC_DIR else REAL_PART
+
+
+def check_snapshot(fixtures_root: Path, file: Path, parser: Parser) -> None:
+ """Сверка файла со снимком для golden-теста: нет снимка или расхождение — падение теста с понятным текстом."""
+ snapshot = golden_path(fixtures_root, file)
+ if not snapshot.is_file():
+ pytest.fail(MISSING.format(path=snapshot.relative_to(fixtures_root).as_posix()), pytrace=False)
+ stored = snapshot.read_text(encoding="utf-8")
+ current = snapshot_text(render(parser, file))
+ if stored != current:
+ diff = unified_diff(stored, current, name=snapshot.relative_to(fixtures_root).as_posix())
+ if len(diff) > MAX_DIFF_CHARS:
+ diff = (
+ diff[:MAX_DIFF_CHARS] + f"\n… (diff обрезан, всего {len(diff)} символов; полный — make golden-update)"
+ )
+ pytest.fail(
+ "результат разбора отличается от снимка (намеренная правка парсера — make golden-update и коммит "
+ f"в fixtures с просмотром diff):\n{diff}",
+ pytrace=False,
+ )
diff --git a/api/tests/integration/test_roundtrip.py b/api/tests/integration/test_roundtrip.py
index b85bc4a..4d2a51b 100644
--- a/api/tests/integration/test_roundtrip.py
+++ b/api/tests/integration/test_roundtrip.py
@@ -1,13 +1,15 @@
"""Интеграционный round-trip сервиса против compose-NATS (без JetStream) и compose-MinIO (`.env.test`).
Приложение поднимается в процессе теста через lifespan (`src.app`) с `NATS_ENABLED=True` — тот же код, что в проде:
-`_connect_s3`, `NatsConnection` с подпиской в queue group `tabular-parser`, `RecognitionWorker` с `NullParser`.
+`_connect_s3`, `NatsConnection` с подпиской в queue group `tabular-parser`, `RecognitionWorker` с парсером по умолчанию
+(`get_parser(settings)` → `OsvParser`); исходник — битый zip, поэтому результат — ошибка `UNREADABLE`.
Тест играет роль ядра: кладёт исходник в MinIO `uploads/…`, публикует запрос в `recognition.tabular.request` с
`Nats-Msg-Id = :` и собирает конверты из `recognition.result`.
Spec tabular-recognition-service: «Ack раньше результата», «Повторный запрос во время обработки» (здесь — после
-обработки: заглушка отвечает мгновенно; повтор во время обработки покрыт unit-тестами воркера), «Запрос с заглушкой
-парсера», «Нет объекта в S3», «Несовместимая версия».
+обработки: битый файл отвергается мгновенно; повтор во время обработки покрыт unit-тестами воркера), ошибка разбора
+конвертом (бывш. «Запрос с заглушкой парсера»), «Нет объекта в S3», «Несовместимая версия»,
+«Реальная ОСВ через воркер» (маркер `fixtures`: реальный XLS из `../fixtures` → `done` и файл результата в MinIO).
Изоляция: `job_id`/`file_id`/ключ S3 уникальны на прогон (uuid4), исходник удаляется после теста; на шине может
работать другой сервис — сообщения `recognition.result` фильтруются по своему `job_id`. Ожидания — по событиям с
@@ -30,12 +32,21 @@ from jsonschema import Draft202012Validator, FormatChecker
from miniopy_async import Minio
from nats.aio.client import Client as NATS
from nats.aio.msg import Msg
+from result_contract import schema_errors
-from src.contract import NATS_MSG_ID_HEADER, RESULT_SUBJECT, TABULAR_REQUEST_SUBJECT, request_msg_id
+from src.contract import (
+ NATS_MSG_ID_HEADER,
+ RESULT_SUBJECT,
+ TABULAR_REQUEST_SUBJECT,
+ RecognitionResult,
+ request_msg_id,
+)
from src.core.config import get_settings
pytestmark = pytest.mark.integration
+REAL_OSV_66 = Path("osv") / "rb" / "2024-FY" / "ОСВ_66 за 2024.xls"
+
CONTRACTS_DIR = Path(__file__).resolve().parents[2] / "contracts"
EXAMPLE_REQUEST = json.loads((CONTRACTS_DIR / "examples" / "request-tabular.json").read_text(encoding="utf-8"))
MESSAGE_VALIDATOR = Draft202012Validator(
@@ -46,11 +57,12 @@ MESSAGE_VALIDATOR = Draft202012Validator(
SERVICE_NAME = "tabular-parser"
READY_TIMEOUT = 5.0 # подключение к NATS и подписка приложения
MESSAGE_TIMEOUT = 10.0 # одно сообщение сервиса (с запасом на повторы публикации)
-# Окно тишины для отрицательных проверок («второго результата нет», «ack нет»): заглушка отвечает за миллисекунды,
+# Окно тишины для отрицательных проверок («второго результата нет», «ack нет»): битый файл отвергается за миллисекунды,
# поэтому лишнее сообщение, если бы оно было, пришло бы гораздо раньше конца окна
QUIET_WINDOW = 1.5
-SOURCE_BYTES = b"PK\x03\x04 round-trip: content is not read by NullParser"
+SOURCE_BYTES = b"PK\x03\x04 round-trip: broken zip, the default parser answers UNREADABLE"
XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+XLS_CONTENT_TYPE = "application/vnd.ms-excel"
app_module = importlib.import_module("src.app") # модуль, а не объект `app`, который реэкспортирует пакет `src`
@@ -146,13 +158,30 @@ class Uploads:
def __init__(self, client: Minio) -> None:
self.client = client
self.keys: list[tuple[str, str]] = []
+ self.buckets: set[str] = set()
- async def put(self, request: dict[str, Any]) -> None:
+ async def put(
+ self, request: dict[str, Any], data: bytes = SOURCE_BYTES, content_type: str = XLSX_CONTENT_TYPE
+ ) -> None:
bucket, key = request["s3"]["bucket"], request["s3"]["key"]
- data = io.BytesIO(SOURCE_BYTES)
- await self.client.put_object(bucket, key, data, len(SOURCE_BYTES), content_type=XLSX_CONTENT_TYPE)
+ await self._ensure_bucket(bucket)
+ await self.client.put_object(bucket, key, io.BytesIO(data), len(data), content_type=content_type)
self.keys.append((bucket, key))
+ async def _ensure_bucket(self, bucket: str) -> None:
+ """`NoSuchBucket` (OQ-80): на чистом MinIO бакета нет — тест создаёт его сам, compose не трогаем."""
+ if bucket not in self.buckets:
+ if not await self.client.bucket_exists(bucket):
+ await self.client.make_bucket(bucket)
+ self.buckets.add(bucket)
+
+ async def read(self, bucket: str, key: str) -> bytes:
+ response = await self.client.get_object(bucket, key)
+ try:
+ return await response.read()
+ finally:
+ response.release()
+
@pytest.fixture
async def uploads() -> AsyncIterator[Uploads]:
@@ -209,14 +238,14 @@ async def core(service: None) -> AsyncIterator[Core]:
# --- сценарии ---------------------------------------------------------------------------------------------------------
-async def test_request_gets_ack_then_not_implemented_and_duplicate_gets_only_ack(core: Core, uploads: Uploads) -> None:
- """«Ack раньше результата», «Запрос с заглушкой парсера», повтор запроса — только ещё один ack."""
+async def test_request_gets_ack_then_parser_error_and_duplicate_gets_only_ack(core: Core, uploads: Uploads) -> None:
+ """«Ack раньше результата», ошибка разбора (`UNREADABLE`) конвертом, повтор запроса — только ещё один ack."""
request = _new_request()
await uploads.put(request)
await core.publish(request)
_assert_ack(await core.next_message(request), request)
- _assert_error(await core.next_message(request), request, "NOT_IMPLEMENTED")
+ _assert_error(await core.next_message(request), request, "UNREADABLE")
await core.publish(request) # та же доставка ещё раз: тот же `Nats-Msg-Id = :`
_assert_ack(await core.next_message(request), request)
@@ -238,7 +267,7 @@ async def test_missing_source_object_gives_file_read_error_and_service_keeps_wor
await uploads.put(following)
await core.publish(following)
_assert_ack(await core.next_message(following), following)
- _assert_error(await core.next_message(following), following, "NOT_IMPLEMENTED")
+ _assert_error(await core.next_message(following), following, "UNREADABLE")
async def test_unsupported_schema_version_gives_single_schema_mismatch_without_ack(
@@ -253,3 +282,34 @@ async def test_unsupported_schema_version_gives_single_schema_mismatch_without_a
body = _assert_error(await core.next_message(request), request, "SCHEMA_MISMATCH")
assert "2.0" in body["error"]["message"]
await core.assert_quiet(request)
+
+
+@pytest.mark.fixtures
+async def test_real_osv_through_worker_gives_done_and_valid_result_file(
+ core: Core, uploads: Uploads, fixtures_dir: Path
+) -> None:
+ """«Реальная ОСВ через воркер»: реальный XLS в MinIO → ack → `done`, `result_schema_version 1.1`, файл по схеме."""
+ request = _new_request(key_name="osv_66_2024.xls")
+ request["file_type"] = "XLS"
+ await uploads.put(request, (fixtures_dir / REAL_OSV_66).read_bytes(), XLS_CONTENT_TYPE)
+
+ await core.publish(request)
+ _assert_ack(await core.next_message(request), request)
+ body = _envelope(await core.next_message(request), request, f"result:{request['job_id']}:{request['attempt']}")
+
+ assert body["status"] == "done", body
+ assert body["result_schema_version"] == "1.1"
+ assert body["metrics"]["duration_ms"] >= 0
+ result_ref = body["result_s3"]
+ assert result_ref["bucket"] == request["s3"]["bucket"]
+ assert result_ref["key"] == f"results/{request['file_id']}/{request['job_id']}/result.json"
+ uploads.keys.append((result_ref["bucket"], result_ref["key"])) # результат тоже убирается после теста
+
+ stored: dict[str, Any] = json.loads(await uploads.read(result_ref["bucket"], result_ref["key"]))
+ assert schema_errors(stored) == []
+ assert RecognitionResult.model_validate(stored).to_wire() == stored
+ assert stored["status"] == "done"
+ assert stored["job_id"] == request["job_id"]
+ assert [document["document_type"] for document in stored["documents"]] == ["OSV"]
+ assert stored["documents"][0]["header"]["account_code_raw"] == "66"
+ await core.assert_quiet(request)
diff --git a/api/tests/result_contract.py b/api/tests/result_contract.py
new file mode 100644
index 0000000..ba71f83
--- /dev/null
+++ b/api/tests/result_contract.py
@@ -0,0 +1,68 @@
+"""Исход разбора → файл результата контракта v1.1 (как его собирает воркер) и проверка JSON-схемой `api/contracts/`.
+
+Общий помощник юнит-тестов парсера и property-теста на реальных файлах: `RecognitionResult.model_validate` и
+`recognition-result.schema.json` (Draft 2020-12, `$ref` — локальный реестр без сети) — оба вердикта обязательны.
+"""
+
+import json
+import uuid
+from pathlib import Path
+from typing import Any
+
+from jsonschema import Draft202012Validator, FormatChecker
+from referencing import Registry, Resource
+
+from src.contract import CONTRACT_VERSION, RecognitionResult
+from src.parser import ParseOutcome
+
+CONTRACTS_DIR = Path(__file__).resolve().parents[1] / "contracts"
+SCHEMA_FILES = (
+ "recognition-request.schema.json",
+ "recognition-result-message.schema.json",
+ "recognition-result.schema.json",
+)
+RESULT_SCHEMA = "recognition-result.schema.json"
+
+
+def _validator() -> Draft202012Validator:
+ schemas = {name: json.loads((CONTRACTS_DIR / name).read_text(encoding="utf-8")) for name in SCHEMA_FILES}
+ registry: Registry = Registry()
+ for schema in schemas.values():
+ registry = registry.with_resource(schema["$id"], Resource.from_contents(schema))
+ return Draft202012Validator(schemas[RESULT_SCHEMA], registry=registry, format_checker=FormatChecker())
+
+
+RESULT_VALIDATOR = _validator()
+
+
+def result_payload(outcome: ParseOutcome) -> dict[str, Any]:
+ """Файл результата из успешного исхода (`done | needs_check`): модель контракта → JSON-словарь."""
+ result = RecognitionResult.model_validate(
+ {
+ "schema_version": CONTRACT_VERSION,
+ "job_id": uuid.uuid4(),
+ "file_id": uuid.uuid4(),
+ "service": {"name": "tabular-parser", "version": "test"},
+ "status": outcome.status,
+ "metrics": outcome.metrics,
+ "documents": list(outcome.documents),
+ }
+ )
+ payload = result.to_wire()
+ # Круг через текст JSON: в схему идёт то, что реально ляжет в S3
+ loaded: dict[str, Any] = json.loads(json.dumps(payload, ensure_ascii=False))
+ return loaded
+
+
+def schema_errors(payload: dict[str, Any]) -> list[str]:
+ """Нарушения JSON-схемы v1.1 (пустой список — файл валиден)."""
+ return [f"{list(error.absolute_path)}: {error.message}" for error in RESULT_VALIDATOR.iter_errors(payload)]
+
+
+def assert_valid_result(outcome: ParseOutcome) -> dict[str, Any]:
+ """Файл результата проходит и pydantic-модель (с round-trip), и JSON-схему; возвращает JSON-словарь."""
+ payload = result_payload(outcome)
+ errors = schema_errors(payload)
+ assert errors == [], "\n".join(errors[:20])
+ assert RecognitionResult.model_validate(payload).to_wire() == payload
+ return payload
diff --git a/api/tests/rules_coverage.py b/api/tests/rules_coverage.py
new file mode 100644
index 0000000..d9230ed
--- /dev/null
+++ b/api/tests/rules_coverage.py
@@ -0,0 +1,77 @@
+"""Помощники теста покрытия словарей (R-22): обход записей `Rules` и счётчик срабатываний `Pattern`.
+
+Вынесены из `tests/fixtures/test_rules_coverage.py`, чтобы сам механизм проверялся без каталога fixtures
+(`tests/unit/test_rules_coverage_helpers.py`).
+"""
+
+import re
+from collections import Counter
+from collections.abc import Iterator
+from typing import Any
+
+import pytest
+from pydantic import BaseModel
+
+from src.parser.rules import Pattern, Rules
+
+DICTIONARIES = {
+ "headers": "osv_headers.yaml",
+ "groups": "osv_groups.yaml",
+ "periods": "periods.yaml",
+ "data_types": "data_types.yaml",
+}
+
+
+def iter_patterns(rules: Rules) -> Iterator[tuple[str, str, Pattern]]:
+ """(файл словаря, путь ключа, Pattern) для каждой записи распознавания; порядок — как в моделях."""
+ for attribute, file_name in DICTIONARIES.items():
+ yield from _walk(file_name, attribute_value=getattr(rules, attribute), path="")
+
+
+def _walk(file_name: str, attribute_value: Any, path: str) -> Iterator[tuple[str, str, Pattern]]:
+ if isinstance(attribute_value, Pattern):
+ yield file_name, path, attribute_value
+ elif isinstance(attribute_value, BaseModel):
+ for name in type(attribute_value).model_fields:
+ yield from _walk(file_name, getattr(attribute_value, name), f"{path}.{name}" if path else name)
+ elif isinstance(attribute_value, dict):
+ for key, inner in attribute_value.items():
+ yield from _walk(file_name, inner, f"{path}.{key}")
+ elif isinstance(attribute_value, list | tuple):
+ for index, inner in enumerate(attribute_value):
+ yield from _walk(file_name, inner, f"{path}[{index}]")
+
+
+class MatchCounter:
+ """Считает совпадения `Pattern.search`/`search_normalized` по идентичности объекта паттерна."""
+
+ def __init__(self) -> None:
+ self.hits: Counter[int] = Counter()
+
+ def install(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ original_search = Pattern.search
+ original_normalized = Pattern.search_normalized
+ counter = self
+
+ def search(pattern: Pattern, text: str) -> re.Match[str] | None:
+ found = original_search(pattern, text)
+ if found is not None:
+ counter.hits[id(pattern)] += 1
+ return found
+
+ def search_normalized(pattern: Pattern, normalized: str) -> re.Match[str] | None:
+ found = original_normalized(pattern, normalized)
+ if found is not None:
+ counter.hits[id(pattern)] += 1
+ return found
+
+ monkeypatch.setattr(Pattern, "search", search)
+ monkeypatch.setattr(Pattern, "search_normalized", search_normalized)
+
+
+def uncovered(rules: Rules, counter: MatchCounter) -> list[str]:
+ return [
+ f"{file_name}: {path}: {pattern.source!r}"
+ for file_name, path, pattern in iter_patterns(rules)
+ if counter.hits[id(pattern)] == 0
+ ]
diff --git a/api/tests/synthetic/__init__.py b/api/tests/synthetic/__init__.py
new file mode 100644
index 0000000..56637b7
--- /dev/null
+++ b/api/tests/synthetic/__init__.py
@@ -0,0 +1 @@
+"""Синтетические ОСВ для форматов, которых нет в реальных файлах: генератор и тесты сценариев спеки."""
diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py
new file mode 100644
index 0000000..21f7326
--- /dev/null
+++ b/api/tests/synthetic/generate.py
@@ -0,0 +1,682 @@
+"""Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`.
+
+Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
+ОСВ (R-22); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl +
+`xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок
+записи фиксирован.
+
+Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
+родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
+строк в файлах разные: парсер не должен на них полагаться.
+"""
+
+import argparse
+import datetime
+import io
+import re
+import sys
+import zipfile
+from collections.abc import Callable, Sequence
+from dataclasses import dataclass, field
+from decimal import Decimal
+from pathlib import Path
+from typing import Literal
+
+import openpyxl
+from openpyxl.styles import Alignment, Font
+
+from tests.synthetic.xlsb_writer import write_xlsb
+
+__all__ = ["FILE_NAMES", "generate_all", "main"]
+
+FIXED_TIME = datetime.datetime(2026, 1, 1, 0, 0, 0)
+ZIP_DATE_TIME = (2026, 1, 1, 0, 0, 0)
+CREATOR = "arm-ka synthetic"
+SHEET = "Лист_1"
+ORG = 'ООО "Тестовая организация"'
+D = Decimal
+ZERO = D(0)
+INDENT_STEP = " " # ведущие пробелы одного уровня, когда отступ ячейки недоступен (xlsb)
+
+Money = D | None
+Pair = tuple[Money, Money]
+Six = tuple[D, D, D, D, D, D]
+Hierarchy = Literal["outline", "indent", "spaces", "none"]
+NumStyle = Literal["num", "text", "text_nbsp", "text_narrow"]
+
+
+# --- Дерево ведомости ---
+
+
+@dataclass
+class Node:
+ """Строка ведомости. Лист: свои начало и обороты, конец вычисляется; родитель: сумма детей по колонкам.
+
+ `indicator` — строка «Вал.»/«Кол.»/«USD» под контрагентом: свои суммы, в сумму родителя не входит.
+ """
+
+ text: str
+ kids: list["Node"] = field(default_factory=list)
+ inn: str | None = None
+ kpp: str | None = None
+ opening: Pair = (None, None)
+ turnover: Pair = (None, None)
+ indicator: bool = False
+ num_style: NumStyle = "num"
+ closing_debit_delta: D = ZERO # порча суммы (s10): к записанному `closing_debit` прибавляется, родитель не меняется
+
+ def children(self) -> list["Node"]:
+ return [kid for kid in self.kids if not kid.indicator]
+
+ def six(self) -> Six:
+ """Шесть сумм по колонкам: начало Д/К, обороты Д/К, конец Д/К (без порчи)."""
+ children = self.children()
+ if children:
+ parts = [kid.six() for kid in children]
+ return tuple(sum((part[i] for part in parts), ZERO) for i in range(6)) # type: ignore[return-value]
+ opening_debit, opening_credit = (value or ZERO for value in self.opening)
+ turnover_debit, turnover_credit = (value or ZERO for value in self.turnover)
+ net = opening_debit - opening_credit + turnover_debit - turnover_credit
+ return (
+ opening_debit,
+ opening_credit,
+ turnover_debit,
+ turnover_credit,
+ net if net > 0 else ZERO,
+ -net if net < 0 else ZERO,
+ )
+
+
+def flatten(node: Node, depth: int = 0) -> list[tuple[Node, int]]:
+ """Строки в порядке обхода в глубину: узел, его глубина."""
+ found = [(node, depth)]
+ for kid in node.kids:
+ found.extend(flatten(kid, depth + 1))
+ return found
+
+
+def leaf(text: str, opening: Pair, turnover: Pair, **kwargs: object) -> Node:
+ return Node(text, opening=opening, turnover=turnover, **kwargs) # type: ignore[arg-type]
+
+
+def money(value: str | None) -> Money:
+ return None if value is None else D(value)
+
+
+def pair(debit: str | None, credit: str | None) -> Pair:
+ return money(debit), money(credit)
+
+
+def alfa(**kwargs: object) -> Node:
+ return leaf("ООО Альфа", pair(None, "250000.50"), pair("180000.25", "420000.00"), inn="7701234567", **kwargs)
+
+
+def beta(**kwargs: object) -> Node:
+ return leaf("ООО Бета", pair("15000.00", None), pair("90000.00", "40000.00"), inn="7707654321", **kwargs)
+
+
+def ivanov() -> Node:
+ return leaf("Иванов Иван", pair(None, "3000.00"), pair("3000.00", None)) # физлицо: без ИНН
+
+
+def gamma(**kwargs: object) -> Node:
+ return leaf("ООО Гамма", pair("120000.00", None), pair("30000.00", "100000.00"), inn="5001234567", **kwargs)
+
+
+def delta(**kwargs: object) -> Node:
+ return leaf("ООО Дельта", pair(None, "8000.10"), pair("8000.10", "12000.35"), inn="7812345678", **kwargs)
+
+
+def sidorov() -> Node:
+ return leaf("ИП Сидоров", pair(None, "1500.00"), pair(None, None), inn="500100732259") # ИНН из 12 цифр
+
+
+KPP = {"ООО Альфа": "770101001", "ООО Бета": "770701001", "ООО Гамма": "500101001", "ООО Дельта": "781201001"}
+
+
+def base_tree(account: str = "60", *, kpp: bool = False) -> Node:
+ """Счёт 60: два субсчёта, шесть контрагентов (с ИНН из 10 и 12 цифр и физлицо без ИНН)."""
+ kids = {"60.01": [alfa(), beta(), ivanov()], "60.02": [gamma(), delta(), sidorov()]}
+ if kpp:
+ for group in kids.values():
+ for node in group:
+ node.kpp = KPP.get(node.text)
+ return Node(account, [Node(sub, items) for sub, items in kids.items()])
+
+
+def receivables_tree() -> Node:
+ """Счёт 62 для второго листа s06."""
+ first = leaf("ООО Покупатель-1", pair("500000.00", None), pair("320000.00", "410000.00"), inn="7728123456")
+ second = leaf("ООО Покупатель-2", pair(None, "20000.00"), pair("75000.00", "55000.00"), inn="7714987654")
+ return Node("62", [Node("62.01", [first, second])])
+
+
+# --- Лист в памяти ---
+
+
+@dataclass
+class CellSpec:
+ value: str | int | D
+ bold: bool = False
+ indent: int = 0
+
+
+@dataclass
+class SheetSpec:
+ name: str
+ cells: dict[tuple[int, int], CellSpec] = field(default_factory=dict)
+ merges: list[tuple[int, int, int, int]] = field(default_factory=list)
+ outline: dict[int, int] = field(default_factory=dict)
+
+ def put(self, row: int, col: int, value: str | int | D, *, bold: bool = False, indent: int = 0) -> None:
+ self.cells[(row, col)] = CellSpec(value, bold, indent)
+
+ def merge(self, top: int, left: int, bottom: int, right: int) -> None:
+ if (top, left) != (bottom, right):
+ self.merges.append((top, left, bottom, right))
+
+
+# --- Шапка и таблица ---
+
+
+@dataclass
+class OsvSpec:
+ """Параметры листа ОСВ: шапка, раскладка колонок и способ выражения иерархии."""
+
+ account: str = "60"
+ period: str = "1 квартал 2025 г."
+ data_type_line: str = "Выводимые данные: БУ (данные бухгалтерского учета)"
+ currency_line: str = "Валюта БУ, НУ: RUB"
+ variant: Literal[1, 2] = 1
+ inn_col: int | None = 5
+ kpp_col: int | None = None
+ first_sum_col: int = 6
+ opening: bool = True
+ collapsed_closing: bool = False
+ hierarchy: Hierarchy = "outline"
+ bold_accounts: bool = False
+ repeat_header_after: int | None = None # после стольких строк данных повторяется заголовок (печать по страницам)
+ detail: str = "Контрагенты, Договоры"
+ detail_label: str = "Детализация по субсчетам, субконто" # метка перед перечнем субконто (вариант 2)
+ org_name: str | None = ORG # None — в шапке нет строки с названием организации
+ group_titles: dict[str, str] | None = None # названия групп сумм вместо `GROUP_TITLES`
+ labels_below: bool = False # вариант 1: «Счет» и «Контрагенты» — отдельными строками под подзаголовками сумм
+
+
+GROUP_TITLES = {
+ "opening": "Сальдо на начало периода",
+ "turnover": "Обороты за период",
+ "closing": "Сальдо на конец периода",
+}
+PAIR_WIDTH = 2 # колонки группы сумм с подзаголовками «Дебет»/«Кредит»
+DEBIT, CREDIT = "Дебет", "Кредит"
+TOTAL_TEXT = "Итого"
+
+
+def _groups(spec: OsvSpec) -> list[tuple[str, int, int]]:
+ """Группы сумм слева направо: (ключ, первая колонка, ширина)."""
+ result: list[tuple[str, int, int]] = []
+ col = spec.first_sum_col
+ for key in ("opening", "turnover", "closing"):
+ if key == "opening" and not spec.opening:
+ continue
+ width = 1 if key == "closing" and spec.collapsed_closing else PAIR_WIDTH
+ result.append((key, col, width))
+ col += width
+ return result
+
+
+def _header_rows(sheet: SheetSpec, spec: OsvSpec, top: int, label: str, sub_label: str) -> int:
+ """Блок заголовка таблицы с `top`: строка групп и строка Дебет/Кредит; возвращает номер следующей строки.
+
+ `labels_below` (только для блока с меткой «Счет»): метки группировок и «ИНН» — отдельными строками под
+ Дебет/Кредит (так печатает 1С), в колонках сумм у них пусто.
+ """
+ titles = spec.group_titles or GROUP_TITLES
+ below = spec.labels_below and bool(label)
+ label_row = top + 2 if below else top # строка меток группировок (первая) и колонок ИНН/КПП
+ sheet.put(label_row, 1, label)
+ sheet.put(label_row + 1, 1, sub_label)
+ service_row = label_row + 1 if below else top + 1
+ if spec.inn_col is not None:
+ sheet.put(service_row, spec.inn_col, "ИНН")
+ if spec.kpp_col is not None:
+ sheet.put(service_row, spec.kpp_col, "КПП")
+ for key, col, width in _groups(spec):
+ sheet.put(top, col, titles[key])
+ sheet.merge(top, col, top, col + width - 1)
+ if width == PAIR_WIDTH:
+ sheet.put(top + 1, col, DEBIT)
+ sheet.put(top + 1, col + 1, CREDIT)
+ return top + 4 if below else top + 2
+
+
+def _header_v1(sheet: SheetSpec, spec: OsvSpec) -> int:
+ if spec.org_name is not None:
+ sheet.put(1, 1, spec.org_name)
+ sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {spec.account} за {spec.period}")
+ sheet.put(4, 1, spec.data_type_line)
+ sheet.put(7, 1, "Параметры:")
+ sheet.put(7, 3, spec.currency_line)
+ return _header_rows(sheet, spec, 9, "Счет", "Контрагенты")
+
+
+def _header_v2(sheet: SheetSpec, spec: OsvSpec) -> int:
+ if spec.org_name is not None:
+ sheet.put(1, 1, spec.org_name)
+ sheet.put(2, 1, f"Оборотно-сальдовая ведомость по счету {spec.account}")
+ sheet.put(3, 1, f"Период: {spec.period}")
+ sheet.put(4, 1, f"{spec.detail_label}: {spec.detail}")
+ sheet.put(5, 1, spec.data_type_line)
+ top = 7
+ nxt = _header_rows(sheet, spec, top, "Субконто", "")
+ sheet.cells.pop((top + 1, 1), None)
+ sheet.merge(top, 1, top + 1, 1)
+ return nxt
+
+
+def _row_cells(sheet: SheetSpec, spec: OsvSpec, row: int, entry: tuple[Node, int], six: Six) -> None:
+ """Одна строка данных (`entry` — узел и его глубина): текст с оформлением иерархии, ИНН/КПП и суммы."""
+ node, depth = entry
+ mode = spec.hierarchy
+ text = INDENT_STEP * depth + node.text if mode == "spaces" else node.text
+ bold = spec.bold_accounts and bool(node.children())
+ sheet.put(row, 1, text, bold=bold, indent=depth if mode == "indent" else 0)
+ if mode == "outline" and depth:
+ sheet.outline[row] = depth
+ if spec.inn_col is not None and node.inn:
+ sheet.put(row, spec.inn_col, node.inn)
+ if spec.kpp_col is not None and node.kpp:
+ sheet.put(row, spec.kpp_col, node.kpp)
+ values = list(six)
+ values[4] += node.closing_debit_delta
+ for key, col, width in _groups(spec):
+ index = {"opening": 0, "turnover": 2, "closing": 4}[key]
+ debit, credit = values[index], values[index + 1]
+ if width == 1: # свёрнутое сальдо: одна колонка, кредитовое — со знаком «минус»
+ _put_number(sheet, row, col, debit - credit, node.num_style)
+ else:
+ _put_number(sheet, row, col, debit, node.num_style)
+ _put_number(sheet, row, col + 1, credit, node.num_style)
+
+
+NBSP = chr(0x00A0) # неразрывный пробел
+NARROW_NBSP = chr(0x202F) # узкий неразрывный пробел
+_SEPARATORS: dict[str, str] = {"text": " ", "text_nbsp": NBSP, "text_narrow": NARROW_NBSP}
+_DASHES: dict[str, str] = {"text": "—", "text_nbsp": "–", "text_narrow": "-"}
+
+
+def _text_number(value: D, style: str) -> str:
+ """Число как печатает человек: «1 234 567,89», «(1 234,00)» для отрицательных, прочерк для нуля."""
+ if value == 0:
+ return _DASHES[style]
+ integer, fraction = f"{abs(value):.2f}".split(".")
+ groups: list[str] = []
+ while integer:
+ groups.insert(0, integer[-3:])
+ integer = integer[:-3]
+ body = f"{_SEPARATORS[style].join(groups)},{fraction}"
+ return f"({body})" if value < 0 else body
+
+
+def _put_number(sheet: SheetSpec, row: int, col: int, value: D, style: str) -> None:
+ if style == "num":
+ if value != 0:
+ sheet.put(row, col, value)
+ return
+ sheet.put(row, col, _text_number(value, style))
+
+
+def build_osv(spec: OsvSpec, root: Node, name: str = SHEET) -> SheetSpec:
+ """Лист ОСВ по дереву `root` и параметрам `spec`."""
+ sheet = SheetSpec(name)
+ next_row = _header_v1(sheet, spec) if spec.variant == 1 else _header_v2(sheet, spec)
+ for index, (node, depth) in enumerate(flatten(root)):
+ if spec.repeat_header_after == index:
+ # Печать по страницам: пустая строка и копия блока заголовка
+ next_row = _header_rows(sheet, spec, next_row + 1, "Счет", "Контрагенты")
+ _row_cells(sheet, spec, next_row, (node, depth), node.six())
+ next_row += 1
+ total = SheetSpec("")
+ _row_cells(total, spec, 1, (Node(TOTAL_TEXT), 0), root.six())
+ for (_, col), cell in total.cells.items():
+ sheet.cells[(next_row, col)] = CellSpec(cell.value, bold=spec.bold_accounts)
+ return sheet
+
+
+# --- Прочие листы ---
+
+
+def reference_sheet(name: str = "Справка") -> SheetSpec:
+ """Посторонний лист: таблица без признаков ОСВ и форм."""
+ sheet = SheetSpec(name)
+ sheet.put(1, 1, "Справка о составе группы")
+ sheet.put(3, 1, "Организация")
+ sheet.put(3, 2, "ИНН")
+ sheet.put(3, 3, "Доля, %")
+ sheet.put(4, 1, 'ООО "Тестовая организация"')
+ sheet.put(4, 2, "7700000001")
+ sheet.put(4, 3, 100)
+ sheet.put(5, 1, "ООО Дочерняя")
+ sheet.put(5, 2, "7700000002")
+ sheet.put(5, 3, 51)
+ return sheet
+
+
+def statement_sheet(name: str = "Бухгалтерский баланс") -> SheetSpec:
+ """Лист формы ББ (признаки «Бухгалтерский баланс» и код формы 0710001): ОСВ-парсер его пропускает."""
+ sheet = SheetSpec(name)
+ sheet.put(1, 1, "Бухгалтерский баланс")
+ sheet.put(2, 1, "на 30 сентября 2025 г.")
+ sheet.put(3, 1, "Форма по ОКУД 0710001")
+ sheet.put(5, 1, "Актив")
+ sheet.put(5, 2, "Код")
+ sheet.put(5, 3, "На 30.09.2025")
+ sheet.put(6, 1, "Основные средства")
+ sheet.put(6, 2, "1150")
+ sheet.put(6, 3, 1200000)
+ sheet.put(7, 1, "Запасы")
+ sheet.put(7, 2, "1210")
+ sheet.put(7, 3, 340000)
+ return sheet
+
+
+def form_sheet(name: str, *lines: str) -> SheetSpec:
+ """Лист формы без таблицы: в шапке только `lines` (так проверяется каждый признак формы по отдельности)."""
+ sheet = SheetSpec(name)
+ for row, line in enumerate(lines, start=1):
+ sheet.put(row, 1, line)
+ sheet.put(len(lines) + 2, 1, "Показатель")
+ sheet.put(len(lines) + 2, 2, "Код")
+ return sheet
+
+
+# --- Файлы ---
+
+
+def _s01() -> list[SheetSpec]:
+ def contract(text: str, opening: Pair, turnover: Pair) -> Node:
+ return leaf(text, opening, turnover)
+
+ first = Node(
+ "ООО Альфа",
+ [
+ contract("Договор поставки № 15 от 10.01.2026", pair(None, "100000.00"), pair("60000.00", "150000.00")),
+ contract("Договор № 7 от 01.02.2026", pair(None, "20000.50"), pair("0.50", "30000.00")),
+ ],
+ inn="7701234567",
+ )
+ second = Node(
+ "ООО Бета",
+ [contract("Договор подряда № 3 от 15.01.2026", pair("15000.00", None), pair("90000.00", "40000.00"))],
+ inn="7707654321",
+ )
+ third = Node(
+ "ООО Гамма",
+ [contract("Договор аренды № 2 от 09.01.2026", pair("120000.00", None), pair("30000.00", "100000.00"))],
+ inn="5001234567",
+ )
+ root = Node("60", [Node("60.01", [first, second]), Node("60.02", [third])])
+ spec = OsvSpec(
+ period="1 квартал 2026 г.",
+ data_type_line="Выводимые данные: Сумма",
+ variant=2,
+ inn_col=5,
+ first_sum_col=6,
+ )
+ return [build_osv(spec, root)]
+
+
+def _s02() -> list[SheetSpec]:
+ spec = OsvSpec(
+ period="2 квартал 2025 г.", currency_line="Валюта БУ, НУ: руб.", hierarchy="indent", inn_col=5, first_sum_col=7
+ )
+ return [build_osv(spec, base_tree())]
+
+
+def _s03() -> list[SheetSpec]:
+ spec = OsvSpec(period="I кв. 2025 г.", hierarchy="none", bold_accounts=True, inn_col=5, first_sum_col=7)
+ return [build_osv(spec, base_tree())]
+
+
+def _s04() -> list[SheetSpec]:
+ first = alfa()
+ first.kids = [
+ leaf("USD", pair(None, "2500.00"), pair("1800.00", "4200.00"), indicator=True),
+ leaf("Кол.", pair("10", None), pair("5", "3"), indicator=True),
+ ]
+ second = beta()
+ second.kids = [leaf("Вал.", pair("1000.00", None), pair(None, "400.00"), indicator=True)]
+ root = Node("60", [Node("60.01", [first, second]), Node("60.02", [gamma()])])
+ return [build_osv(OsvSpec(period="3 квартал 2025 г.", inn_col=5, first_sum_col=7), root)]
+
+
+def _s05() -> list[SheetSpec]:
+ first = leaf(
+ "ООО Альфа",
+ pair("1234567.89", None),
+ pair("500.00", "-1234.00"),
+ inn="7701234567",
+ num_style="text",
+ )
+ second = beta(num_style="text_nbsp")
+ third = gamma(num_style="text_narrow")
+ root = Node("60", [Node("60.01", [first, second]), Node("60.02", [third, delta()])])
+ return [build_osv(OsvSpec(period="полугодие 2025 г.", inn_col=4, first_sum_col=5), root)]
+
+
+def _s06() -> list[SheetSpec]:
+ first = build_osv(OsvSpec(account="60", inn_col=5, first_sum_col=6), base_tree(), "ОСВ 60")
+ second = build_osv(OsvSpec(account="62", inn_col=3, first_sum_col=4), receivables_tree(), "ОСВ 62")
+ return [first, reference_sheet(), second]
+
+
+def _s07() -> list[SheetSpec]:
+ # Смешанные знаки у детей (кредитовое у Альфы, дебетовое у Беты) намеренно проверяют правило R-21: свёрнутое сальдо
+ # родителя сверяется по нетто, а не по сумме модулей
+ first = leaf("ООО Альфа", pair(None, None), pair("100000.00", "350000.50"), inn="7701234567")
+ second = leaf("ООО Бета", pair(None, None), pair("60000.00", "10000.00"), inn="7707654321")
+ third = leaf("ООО Гамма", pair(None, None), pair("20000.00", "20000.00"), inn="5001234567")
+ fourth = leaf("ООО Дельта", pair(None, None), pair("1000.00", "33000.40"), inn="7812345678")
+ root = Node("60", [Node("60.01", [first, second]), Node("60.02", [third, fourth])])
+ spec = OsvSpec(period="9 месяцев 2025 г.", opening=False, collapsed_closing=True, inn_col=5, first_sum_col=7)
+ return [build_osv(spec, root)]
+
+
+def _s08() -> list[SheetSpec]:
+ spec = OsvSpec(period="период с 01.01.2025 по 31.03.2025", inn_col=5, kpp_col=6, first_sum_col=7)
+ return [build_osv(spec, base_tree(kpp=True))]
+
+
+def _s09() -> list[SheetSpec]:
+ named = Node("60.01, Расчеты с поставщиками и подрядчиками", [alfa(), beta()])
+ advances = Node("60.02, Расчеты по авансам выданным", [gamma()])
+ letter = Node("60.АВ, Расчеты по авансам (буквенный субсчёт)", [delta()])
+ root = Node("60", [named, advances, letter])
+ return [build_osv(OsvSpec(period="4 квартал 2025 г.", inn_col=5, first_sum_col=7), root)]
+
+
+def _s10() -> list[SheetSpec]:
+ tree = base_tree()
+ broken = tree.kids[0].kids[1]
+ assert broken.text == "ООО Бета"
+ broken.closing_debit_delta = D(1)
+ return [build_osv(OsvSpec(inn_col=5, first_sum_col=7), tree)]
+
+
+def _s11() -> list[SheetSpec]:
+ spec = OsvSpec(period="12 месяцев 2025 г.", repeat_header_after=4, inn_col=5, first_sum_col=7)
+ return [build_osv(spec, base_tree())]
+
+
+def _s12() -> list[SheetSpec]:
+ spec = OsvSpec(
+ period="январь - сентябрь 2025 г.",
+ data_type_line="Выводимые данные: НУ (данные налогового учета)",
+ inn_col=5,
+ first_sum_col=7,
+ )
+ return [statement_sheet(), build_osv(spec, base_tree(), "ОСВ 60")]
+
+
+def _s13() -> list[SheetSpec]:
+ spec = OsvSpec(
+ period="2 квартал 2025 г.", currency_line="Валюта БУ, НУ: руб.", hierarchy="spaces", inn_col=5, first_sum_col=7
+ )
+ return [build_osv(spec, base_tree())]
+
+
+def _s14() -> list[SheetSpec]:
+ """Три листа форм, опознаваемые каждый одним своим признаком (покрытие `statement_markers`), и лист ОСВ."""
+ return [
+ form_sheet("Форма 1", "Форма по ОКУД 0710001"),
+ form_sheet("ОФР", "Отчет о финансовых результатах"),
+ form_sheet("Форма 2", "Форма по ОКУД 0710002"),
+ build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree(), "ОСВ 60"),
+ ]
+
+
+def _s15() -> list[SheetSpec]:
+ """Названия групп сумм: «Входящее/Конечное сальдо» на первом листе, «Начальное/Исходящее сальдо» на втором."""
+ first = OsvSpec(
+ account="60",
+ inn_col=5,
+ first_sum_col=7,
+ group_titles={"opening": "Входящее сальдо", "turnover": "Обороты", "closing": "Конечное сальдо"},
+ )
+ second = OsvSpec(
+ account="62",
+ inn_col=3,
+ first_sum_col=4,
+ group_titles={"opening": "Начальное сальдо", "turnover": "Обороты", "closing": "Исходящее сальдо"},
+ )
+ return [build_osv(first, base_tree(), "ОСВ 60"), build_osv(second, receivables_tree(), "ОСВ 62")]
+
+
+def _s16() -> list[SheetSpec]:
+ """Шапка без названия организации; период — только год; валюта с пояснением; метки группировок отдельными
+ строками («Счет» среди них); перечень «Детализация: …» без слова «субконто» и группировки вне ключевых."""
+ first = OsvSpec(
+ account="60",
+ period="2024",
+ currency_line="Валюта БУ, НУ: RUB (код 643)",
+ org_name=None,
+ labels_below=True,
+ inn_col=5,
+ first_sum_col=7,
+ )
+ second = OsvSpec(
+ account="62",
+ variant=2,
+ period="2024",
+ org_name=None,
+ detail_label="Детализация по субсчетам",
+ detail="Номенклатура, Статьи затрат, Нематериальные активы, Валюта",
+ inn_col=None,
+ first_sum_col=3,
+ )
+ return [build_osv(first, base_tree(), "ОСВ 60"), build_osv(second, receivables_tree(), "ОСВ 62")]
+
+
+# --- Запись файлов ---
+
+
+_MODIFIED_RE = re.compile(rb"(]*>)[^<]*()")
+_CORE_PART = "docProps/core.xml"
+_FIXED_W3CDTF = FIXED_TIME.strftime("%Y-%m-%dT%H:%M:%SZ").encode()
+
+
+def _normalized_zip(data: bytes) -> bytes:
+ """Тот же архив с постоянными метками времени: записей zip и `dcterms:modified` (openpyxl ставит текущее время
+ при каждом `save`, заданное в свойствах значение перезаписывает)."""
+ out = io.BytesIO()
+ with zipfile.ZipFile(io.BytesIO(data)) as source, zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as target:
+ for info in source.infolist():
+ clone = zipfile.ZipInfo(info.filename, date_time=ZIP_DATE_TIME)
+ clone.compress_type = zipfile.ZIP_DEFLATED
+ clone.external_attr = info.external_attr
+ content = source.read(info.filename)
+ if info.filename == _CORE_PART:
+ content = _MODIFIED_RE.sub(rb"\g<1>" + _FIXED_W3CDTF + rb"\g<2>", content)
+ target.writestr(clone, content)
+ return out.getvalue()
+
+
+def to_xlsx(sheets: Sequence[SheetSpec]) -> bytes:
+ book = openpyxl.Workbook()
+ default = book.active
+ assert default is not None
+ book.remove(default)
+ book.properties.creator = CREATOR
+ book.properties.lastModifiedBy = CREATOR
+ book.properties.created = FIXED_TIME
+ book.properties.modified = FIXED_TIME
+ for spec in sheets:
+ ws = book.create_sheet(spec.name)
+ for (row, col), cell in sorted(spec.cells.items()):
+ target = ws.cell(row, col, cell.value)
+ if cell.bold:
+ target.font = Font(bold=True)
+ if cell.indent:
+ target.alignment = Alignment(indent=cell.indent)
+ for top, left, bottom, right in spec.merges:
+ ws.merge_cells(start_row=top, start_column=left, end_row=bottom, end_column=right)
+ for row, level in sorted(spec.outline.items()):
+ ws.row_dimensions[row].outline_level = level
+ ws.column_dimensions["A"].width = 48
+ buffer = io.BytesIO()
+ book.save(buffer)
+ return _normalized_zip(buffer.getvalue())
+
+
+def to_xlsb(sheets: Sequence[SheetSpec]) -> bytes:
+ """xlsb без стилей и объединений: отступ уже выражен ведущими пробелами текста (`hierarchy="spaces"`)."""
+ return write_xlsb({spec.name: {key: cell.value for key, cell in spec.cells.items()} for spec in sheets})
+
+
+FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSpec]], bytes]]] = {
+ "s01_variant2_header.xlsx": (_s01, to_xlsx),
+ "s02_no_outline_indent.xlsx": (_s02, to_xlsx),
+ "s03_no_outline_no_indent.xlsx": (_s03, to_xlsx),
+ "s04_currency_quantity_rows.xlsx": (_s04, to_xlsx),
+ "s05_text_numbers.xlsx": (_s05, to_xlsx),
+ "s06_two_osv_and_other_sheet.xlsx": (_s06, to_xlsx),
+ "s07_no_opening_collapsed_closing.xlsx": (_s07, to_xlsx),
+ "s08_extra_kpp_column.xlsx": (_s08, to_xlsx),
+ "s09_subaccount_names.xlsx": (_s09, to_xlsx),
+ "s10_broken_sum.xlsx": (_s10, to_xlsx),
+ "s11_header_repeat.xlsx": (_s11, to_xlsx),
+ "s12_statement_and_osv.xlsx": (_s12, to_xlsx),
+ "s13_no_outline_indent.xlsb": (_s13, to_xlsb),
+ "s14_statement_forms.xlsx": (_s14, to_xlsx),
+ "s15_group_title_synonyms.xlsx": (_s15, to_xlsx),
+ "s16_header_synonyms.xlsx": (_s16, to_xlsx),
+}
+FILE_NAMES = tuple(FILES)
+
+
+def generate_all(directory: Path) -> list[Path]:
+ """Записывает файлы набора в `directory` (создаётся); возвращает пути в порядке `FILE_NAMES`."""
+ directory.mkdir(parents=True, exist_ok=True)
+ written: list[Path] = []
+ for name, (build, serialize) in FILES.items():
+ path = directory / name
+ path.write_bytes(serialize(build()))
+ written.append(path)
+ return written
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ prog="python -m tests.synthetic.generate",
+ description="Синтетические ОСВ s01…s16 (xlsx и один xlsb) в указанный каталог.",
+ )
+ parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
+ args = parser.parse_args(argv)
+ for path in generate_all(args.directory):
+ print(path)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/api/tests/synthetic/test_generate.py b/api/tests/synthetic/test_generate.py
new file mode 100644
index 0000000..fcaac74
--- /dev/null
+++ b/api/tests/synthetic/test_generate.py
@@ -0,0 +1,72 @@
+"""Генератор синтетики: детерминированность, состав набора и читаемость xlsb-писателя (не требует каталога fixtures)."""
+
+import re
+import time
+from decimal import Decimal
+from pathlib import Path
+
+from src.parser.readers import read_book
+from tests.synthetic.generate import FILE_NAMES, generate_all
+from tests.synthetic.xlsb_writer import write_xlsb
+
+# Сколько ждать между двумя генерациями: у записей zip разрешение метки времени — 2 с
+ZIP_TIME_RESOLUTION_SECONDS = 2.1
+LONG_TEXT_LENGTH = 400
+BIG_ROW = 70000
+
+
+def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None:
+ """Повторный запуск (позже, чем разрешение меток времени) даёт те же байты для каждого файла набора."""
+ first = generate_all(tmp_path / "first")
+ time.sleep(ZIP_TIME_RESOLUTION_SECONDS)
+ second = generate_all(tmp_path / "second")
+
+ assert [path.name for path in first] == [path.name for path in second]
+ for left, right in zip(first, second, strict=True):
+ assert left.read_bytes() == right.read_bytes(), left.name
+
+
+def test_generated_set_is_s01_to_s16_with_one_xlsb(tmp_path: Path) -> None:
+ names = [path.name for path in generate_all(tmp_path)]
+
+ assert names == list(FILE_NAMES)
+ assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names)
+ assert [name[1:3] for name in names] == [f"{number:02d}" for number in range(1, len(names) + 1)]
+ assert [name for name in names if name.endswith(".xlsb")] == ["s13_no_outline_indent.xlsb"]
+ assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names)
+
+
+def test_xlsb_writer_round_trips_through_the_reader() -> None:
+ """Тексты (с ведущими пробелами и кириллицей), числа и несколько листов читаются `src.parser.readers`."""
+ long_text = "щ" * LONG_TEXT_LENGTH # запись длиннее 127 байт: длина кодируется двумя байтами
+ data = write_xlsb(
+ {
+ "Лист_1": {
+ (1, 1): "Привет",
+ (2, 3): Decimal("1234567.89"),
+ (3, 1): " отступ",
+ (4, 2): long_text,
+ (BIG_ROW, 4): 7,
+ },
+ "Второй": {(1, 1): "Привет"},
+ }
+ )
+
+ sheets, warnings = read_book(data, "XLSB")
+
+ assert [sheet.name for sheet in sheets] == ["Лист_1", "Второй"]
+ first = sheets[0]
+ assert first.text_at(1, 1) == "Привет"
+ assert first.cell(2, 3).value == Decimal("1234567.89")
+ assert (first.text_at(3, 1), first.cell(3, 1).indent) == ("отступ", 4)
+ assert first.text_at(4, 2) == long_text
+ assert first.cell(BIG_ROW, 4).value == Decimal(7)
+ assert first.n_rows == BIG_ROW
+ assert sheets[1].text_at(1, 1) == "Привет"
+ assert warnings == ["xlsb: outline level недоступен"]
+
+
+def test_xlsb_writer_is_deterministic() -> None:
+ cells = {"Лист_1": {(1, 1): "а", (2, 2): Decimal("1.50")}}
+
+ assert write_xlsb(cells) == write_xlsb(cells)
diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py
new file mode 100644
index 0000000..04717d6
--- /dev/null
+++ b/api/tests/synthetic/test_synthetic_files.py
@@ -0,0 +1,485 @@
+"""Сценарии спеки `osv-parsing` на синтетических файлах `../fixtures/osv/synthetic/` (Срез 2a, Task 11).
+
+Ожидания выведены из сценариев спеки и из содержимого, которое генератор записывает в файл (суммы и тексты ниже
+перечислены в тесте явно), а не из текущего вывода парсера: файл — вход, оракулом он не служит. Без каталога fixtures
+тесты пропускаются (маркер `fixtures`).
+"""
+
+from dataclasses import dataclass
+from decimal import Decimal
+from pathlib import Path
+from typing import cast
+
+import pytest
+from result_contract import assert_valid_result
+
+from src.contract import FileType, OsvDocument, ResultStatus
+from src.contract.result import OsvRow
+from src.parser import ParseOutcome, get_parser
+
+pytestmark = pytest.mark.fixtures
+
+
+@pytest.fixture(autouse=True)
+def _require_synthetic(synthetic_dir: Path) -> None:
+ """T11.1: fixtures есть, а `osv/synthetic` нет/пуст (fixtures на main до слияния ветки) — skip, а не падение."""
+
+
+SYNTHETIC_DIR = Path("osv") / "synthetic"
+EXPECTED_NAMES = [
+ "s01_variant2_header.xlsx",
+ "s02_no_outline_indent.xlsx",
+ "s03_no_outline_no_indent.xlsx",
+ "s04_currency_quantity_rows.xlsx",
+ "s05_text_numbers.xlsx",
+ "s06_two_osv_and_other_sheet.xlsx",
+ "s07_no_opening_collapsed_closing.xlsx",
+ "s08_extra_kpp_column.xlsx",
+ "s09_subaccount_names.xlsx",
+ "s10_broken_sum.xlsx",
+ "s11_header_repeat.xlsx",
+ "s12_statement_and_osv.xlsx",
+ "s13_no_outline_indent.xlsb",
+ "s14_statement_forms.xlsx",
+ "s15_group_title_synonyms.xlsx",
+ "s16_header_synonyms.xlsx",
+]
+XLSB_NAME = "s13_no_outline_indent.xlsb"
+XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
+ORG = 'ООО "Тестовая организация"'
+VARIANT_2 = 2
+XLSB_WARNING = "xlsb: outline level недоступен"
+
+ALFA = "ООО Альфа"
+BETA = "ООО Бета"
+GAMMA = "ООО Гамма"
+D = Decimal
+
+
+@dataclass(frozen=True)
+class Expect:
+ """Ожидания по файлу, общие для всех сценариев (шапка и итог разбора)."""
+
+ hierarchy: str
+ year: int
+ quarter: str
+ cumulative: bool
+ status: ResultStatus = ResultStatus.DONE
+ documents: int = 1
+ variant: int = 1
+ data_type: str = "BU"
+ organization: str = ORG
+ # Вариант шапки по документам (если у листов файла он разный); пусто — `variant` у всех
+ variants: tuple[int, ...] = ()
+
+
+EXPECTED: dict[str, Expect] = {
+ # «Период: 1 квартал 2026 г.» — вариант 2, данные «сумма»
+ "s01_variant2_header.xlsx": Expect("outline", 2026, "Q1", True, variant=2, data_type="SUM"),
+ "s02_no_outline_indent.xlsx": Expect("indent", 2025, "Q2", False),
+ # римский квартал «I кв.»
+ "s03_no_outline_no_indent.xlsx": Expect("account_pattern", 2025, "Q1", True),
+ "s04_currency_quantity_rows.xlsx": Expect("outline", 2025, "Q3", False),
+ # «полугодие»
+ "s05_text_numbers.xlsx": Expect("outline", 2025, "Q2", True),
+ "s06_two_osv_and_other_sheet.xlsx": Expect("outline", 2025, "Q1", True, documents=2),
+ # «9 месяцев»
+ "s07_no_opening_collapsed_closing.xlsx": Expect("outline", 2025, "Q3", True),
+ # диапазон дат «с 01.01.2025 по 31.03.2025»
+ "s08_extra_kpp_column.xlsx": Expect("outline", 2025, "Q1", True),
+ "s09_subaccount_names.xlsx": Expect("outline", 2025, "Q4", False),
+ "s10_broken_sum.xlsx": Expect("outline", 2025, "Q1", True, status=ResultStatus.NEEDS_CHECK),
+ # «12 месяцев»
+ "s11_header_repeat.xlsx": Expect("outline", 2025, "Q4", True),
+ # «январь - сентябрь», данные «НУ»
+ "s12_statement_and_osv.xlsx": Expect("outline", 2025, "Q3", True, data_type="NU"),
+ "s13_no_outline_indent.xlsb": Expect("indent", 2025, "Q2", False),
+ # три листа форм по одному признаку + лист ОСВ
+ "s14_statement_forms.xlsx": Expect("outline", 2025, "Q1", True),
+ # названия групп сумм: «Входящее/Конечное», «Начальное/Исходящее сальдо»
+ "s15_group_title_synonyms.xlsx": Expect("outline", 2025, "Q1", True, documents=2),
+ # шапка без организации, период «2024»; лист 1 — вариант 1, лист 2 — вариант 2
+ "s16_header_synonyms.xlsx": Expect("outline", 2024, "Q4", True, documents=2, organization="", variants=(1, 2)),
+}
+
+
+# --- Помощники ---
+
+
+def synthetic_path(fixtures_dir: Path, name: str) -> Path:
+ return fixtures_dir / SYNTHETIC_DIR / name
+
+
+def parse_path(path: Path) -> ParseOutcome:
+ return get_parser().parse(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper()))
+
+
+def parse_named(fixtures_dir: Path, name: str) -> ParseOutcome:
+ path = synthetic_path(fixtures_dir, name)
+ if not path.is_file():
+ if name.endswith(".xlsb"):
+ pytest.skip(XLSB_MISSING_REASON)
+ pytest.fail(f"нет файла {name}: запустите генератор (см. README fixtures)")
+ return parse_path(path)
+
+
+def documents(outcome: ParseOutcome) -> list[OsvDocument]:
+ assert outcome.error is None, outcome.error
+ docs = list(outcome.documents)
+ assert all(isinstance(doc, OsvDocument) for doc in docs)
+ return cast(list[OsvDocument], docs)
+
+
+def one_document(outcome: ParseOutcome) -> OsvDocument:
+ (doc,) = documents(outcome)
+ return doc
+
+
+def warnings_of(outcome: ParseOutcome) -> list[str]:
+ assert outcome.metrics is not None
+ assert outcome.metrics.warnings is not None
+ return outcome.metrics.warnings
+
+
+def by_text(doc: OsvDocument) -> dict[str, OsvRow]:
+ """Строки по тексту (в синтетике тексты строк уникальны внутри листа)."""
+ rows = {row.row_value: row for row in doc.rows}
+ assert len(rows) == len(doc.rows), "тексты строк синтетики должны быть уникальны"
+ return rows
+
+
+def rows_text(doc: OsvDocument) -> list[str]:
+ return [row.row_value for row in doc.rows]
+
+
+def sums(row: OsvRow) -> tuple[Decimal | None, ...]:
+ return (
+ row.opening_debit,
+ row.opening_credit,
+ row.turnover_debit,
+ row.turnover_credit,
+ row.closing_debit,
+ row.closing_credit,
+ )
+
+
+def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | None, ...]]]:
+ """Дерево документа: текст, уровень, родитель, суммы — для сравнения двух файлов одной ведомости."""
+ return [(row.row_value, row.level, row.parent_row_number, sums(row)) for row in doc.rows]
+
+
+# --- Состав набора и общие ожидания ---
+
+
+def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
+ """В `fixtures/osv/synthetic/` лежат файлы набора s01…s16 (s13 — xlsb; допустима деградация: нет только его)."""
+ present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file())
+ assert present == [name for name in EXPECTED_NAMES if name in present]
+ missing = [name for name in EXPECTED_NAMES if name not in present]
+ assert missing in ([], [XLSB_NAME]), f"нет файлов набора: {missing}"
+
+
+def test_every_synthetic_file_is_listed(synthetic_file: Path) -> None:
+ """Каждый файл каталога описан в таблице ожиданий (новый файл без ожиданий — ошибка набора)."""
+ assert synthetic_file.name in EXPECTED, f"добавьте ожидания для {synthetic_file.name}"
+
+
+def test_header_status_and_hierarchy(synthetic_file: Path) -> None:
+ """Статус, число документов, стратегия иерархии и шапка из таблицы `EXPECTED`; результат валиден по контракту."""
+ expect = EXPECTED[synthetic_file.name]
+
+ outcome = parse_path(synthetic_file)
+
+ assert outcome.status is expect.status, outcome.metrics
+ docs = documents(outcome)
+ assert len(docs) == expect.documents
+ hierarchy_lines = [w for w in warnings_of(outcome) if f"hierarchy={expect.hierarchy}" in w]
+ assert len(hierarchy_lines) == expect.documents, warnings_of(outcome)
+ variants = expect.variants or (expect.variant,) * expect.documents
+ assert [doc.header.header_variant for doc in docs] == list(variants)
+ for doc in docs:
+ assert doc.header.period_year == expect.year
+ assert doc.header.period_quarter == expect.quarter
+ assert doc.header.is_cumulative is expect.cumulative
+ assert doc.header.data_type == expect.data_type
+ assert doc.header.organization_name == expect.organization
+ assert doc.rows[-1].group_type == "TOTAL"
+ if expect.status is ResultStatus.DONE:
+ assert doc.checks is not None
+ assert doc.checks.total_row_matches_root is True
+ assert doc.checks.balance_identity_violations == []
+ assert doc.checks.parent_sum_violations == []
+ assert_valid_result(outcome)
+
+
+# --- Сценарии спеки ---
+
+
+def test_s01_variant2_header_and_subkonto_groups(fixtures_dir: Path) -> None:
+ """«Вариант 2»: `header_variant = 2`, `group_schema = [ACCOUNT, COUNTERPARTY, CONTRACT]`, `period_quarter = Q1`."""
+ doc = one_document(parse_named(fixtures_dir, "s01_variant2_header.xlsx"))
+
+ assert doc.header.header_variant == VARIANT_2
+ assert [group.type for group in doc.group_schema] == ["ACCOUNT", "COUNTERPARTY", "CONTRACT"]
+ assert doc.header.period_quarter == "Q1"
+ assert doc.header.period_text == "1 квартал 2026 г."
+ rows = by_text(doc)
+ contract = rows["Договор поставки № 15 от 10.01.2026"]
+ assert contract.group_type == "CONTRACT"
+ assert contract.parent_row_number == rows[ALFA].row_number
+ assert rows[ALFA].group_type == "COUNTERPARTY"
+ assert rows[ALFA].counterparty_inn == "7701234567"
+
+
+def test_s02_indent_levels_and_tree(fixtures_dir: Path) -> None:
+ """«Без outline level»: `hierarchy=indent`, уровни 0/1/2, родитель контрагента — субсчёт."""
+ doc = one_document(parse_named(fixtures_dir, "s02_no_outline_indent.xlsx"))
+
+ rows = by_text(doc)
+ assert [(text, rows[text].level) for text in ("60", "60.01", ALFA, "60.02", GAMMA, "Итого")] == [
+ ("60", 0),
+ ("60.01", 1),
+ (ALFA, 2),
+ ("60.02", 1),
+ (GAMMA, 2),
+ ("Итого", 0),
+ ]
+ assert rows[ALFA].parent_row_number == rows["60.01"].row_number
+ assert rows["60.01"].parent_row_number == rows["60"].row_number
+ assert rows["60"].parent_row_number is None
+
+
+def test_s02_and_s03_describe_the_same_tree(fixtures_dir: Path) -> None:
+ """Одна ведомость с отступами (s02) и с кодами счетов и жирностью (s03): дерево и суммы совпадают."""
+ indent = one_document(parse_named(fixtures_dir, "s02_no_outline_indent.xlsx"))
+ pattern = one_document(parse_named(fixtures_dir, "s03_no_outline_no_indent.xlsx"))
+
+ assert tree(indent) == tree(pattern)
+
+
+def test_s03_account_pattern_levels(fixtures_dir: Path) -> None:
+ """«Без outline и отступов»: уровни по кодам счетов и жирности, контрольные суммы сходятся (в общем тесте)."""
+ outcome = parse_named(fixtures_dir, "s03_no_outline_no_indent.xlsx")
+ doc = one_document(outcome)
+
+ rows = by_text(doc)
+ assert [rows[text].level for text in ("60", "60.01", ALFA, BETA, "Иванов Иван", "60.02", GAMMA)] == [
+ 0,
+ 1,
+ 2,
+ 2,
+ 2,
+ 1,
+ 2,
+ ]
+ assert rows[GAMMA].parent_row_number == rows["60.02"].row_number
+ assert outcome.status is ResultStatus.DONE
+
+
+def test_s04_currency_and_quantity_rows(fixtures_dir: Path) -> None:
+ """«Строки Вал./Кол.»: USD и Вал. — CURRENCY, Кол. — QUANTITY; в проверку суммы детей не входят, статус `done`."""
+ outcome = parse_named(fixtures_dir, "s04_currency_quantity_rows.xlsx")
+ doc = one_document(outcome)
+
+ rows = by_text(doc)
+ assert rows["USD"].data_type == "CURRENCY"
+ assert rows["Кол."].data_type == "QUANTITY"
+ assert rows["Вал."].data_type == "CURRENCY"
+ assert rows["USD"].parent_row_number == rows[ALFA].row_number
+ assert rows["Вал."].parent_row_number == rows[BETA].row_number
+ assert rows[ALFA].data_type == "BU"
+ assert rows[ALFA].group_type == "COUNTERPARTY"
+ # суммы показателей остаются в шести полях
+ assert rows["Кол."].opening_debit == D("10.00")
+ assert rows["USD"].opening_credit == D("2500.00")
+ assert outcome.status is ResultStatus.DONE
+
+
+def test_s05_text_numbers(fixtures_dir: Path) -> None:
+ """«Текстовые числа»: «1 234 567,89» → 1234567.89, «(1 234,00)» → -1234.00, «—»/«–»/«-» → null."""
+ doc = one_document(parse_named(fixtures_dir, "s05_text_numbers.xlsx"))
+
+ rows = by_text(doc)
+ assert sums(rows[ALFA]) == (D("1234567.89"), None, D("500.00"), D("-1234.00"), D("1236301.89"), None)
+ # неразрывные пробелы и «–»
+ assert sums(rows[BETA]) == (D("15000.00"), None, D("90000.00"), D("40000.00"), D("65000.00"), None)
+ # узкие неразрывные пробелы и «-»
+ assert sums(rows[GAMMA]) == (D("120000.00"), None, D("30000.00"), D("100000.00"), D("50000.00"), None)
+
+
+def test_s06_two_osv_sheets_and_other_sheet(fixtures_dir: Path) -> None:
+ """«Два листа ОСВ и посторонний лист»: два документа в порядке листов, в warnings — пропуск листа «Справка»."""
+ outcome = parse_named(fixtures_dir, "s06_two_osv_and_other_sheet.xlsx")
+
+ docs = documents(outcome)
+ assert [doc.locator for doc in docs] == ["sheet:ОСВ 60", "sheet:ОСВ 62"]
+ assert [doc.header.account_code_raw for doc in docs] == ["60", "62"]
+ assert any("Справка" in warning and "skipped" in warning for warning in warnings_of(outcome))
+ assert not any("ОСВ 60" in w and "skipped" in w or "ОСВ 62" in w and "skipped" in w for w in warnings_of(outcome))
+ assert outcome.metrics is not None
+ assert outcome.metrics.rows == sum(1 for doc in docs for row in doc.rows if row.group_type != "TOTAL")
+
+
+def test_s07_no_opening_and_collapsed_closing(fixtures_dir: Path) -> None:
+ """«Без начала и со свёрнутым сальдо»: `opening_* = null`, отрицательное свёрнутое — в `closing_credit`, warning.
+
+ T11.2: смешанные знаки у детей (Альфа — кредитовое, Бета — дебетовое) намеренно проверяют правило R-21: свёрнутое
+ сальдо родителя сверяется по нетто (дебет минус кредит), а не по сумме модулей.
+ """
+ outcome = parse_named(fixtures_dir, "s07_no_opening_collapsed_closing.xlsx")
+ doc = one_document(outcome)
+
+ for row in doc.rows:
+ assert row.opening_debit is None
+ assert row.opening_credit is None
+ rows = by_text(doc)
+ assert (rows[ALFA].closing_debit, rows[ALFA].closing_credit) == (None, D("250000.50"))
+ assert (rows[BETA].closing_debit, rows[BETA].closing_credit) == (D("50000.00"), None)
+ assert (rows[GAMMA].closing_debit, rows[GAMMA].closing_credit) == (None, None)
+ assert rows[ALFA].turnover_credit == D("350000.50")
+ assert any("свёрнутое сальдо" in warning for warning in warnings_of(outcome))
+ assert outcome.status is ResultStatus.DONE
+
+
+def test_s08_kpp_column_goes_to_extra(fixtures_dir: Path) -> None:
+ """«Неизвестная колонка внутри таблицы»: `extra["КПП"]` у контрагентов, суммы не сдвинуты, ИНН в своей колонке."""
+ doc = one_document(parse_named(fixtures_dir, "s08_extra_kpp_column.xlsx"))
+
+ rows = by_text(doc)
+ assert rows[ALFA].extra == {"КПП": "770101001"}
+ assert rows[BETA].extra == {"КПП": "770701001"}
+ assert rows["Иванов Иван"].extra is None
+ assert rows["60"].extra is None
+ assert rows[ALFA].counterparty_inn == "7701234567"
+ assert sums(rows[ALFA]) == (None, D("250000.50"), D("180000.25"), D("420000.00"), None, D("490000.25"))
+ assert doc.columns is not None
+ assert doc.columns["inn"] == "E"
+ assert (doc.columns["opening_debit"], doc.columns["closing_credit"]) == ("G", "L")
+
+
+def test_s09_subaccount_names_in_account_rows(fixtures_dir: Path) -> None:
+ """«Название субсчёта в строке счёта»: ACCOUNT, `account_code_raw` без названия, `row_value` — исходный текст."""
+ doc = one_document(parse_named(fixtures_dir, "s09_subaccount_names.xlsx"))
+
+ rows = by_text(doc)
+ named = rows["60.01, Расчеты с поставщиками и подрядчиками"]
+ assert (named.group_type, named.account_code_raw) == ("ACCOUNT", "60.01")
+ assert rows["60.02, Расчеты по авансам выданным"].account_code_raw == "60.02"
+ # буквенный субсчёт (R-16): счёт, а не контрагент; дети наследуют его код
+ letter = rows["60.АВ, Расчеты по авансам (буквенный субсчёт)"]
+ assert (letter.group_type, letter.account_code_raw) == ("ACCOUNT", "60.АВ")
+ assert rows["ООО Дельта"].account_code_raw == "60.АВ"
+ assert rows["ООО Дельта"].group_type == "COUNTERPARTY"
+ assert rows[ALFA].account_code_raw == "60.01"
+
+
+def test_s10_broken_sum_needs_check(fixtures_dir: Path) -> None:
+ """«Испорченная сумма»: `needs_check`, нарушение тождества — у контрагента, суммы родителя — у его субсчёта."""
+ outcome = parse_named(fixtures_dir, "s10_broken_sum.xlsx")
+ doc = one_document(outcome)
+
+ rows = by_text(doc)
+ assert outcome.status is ResultStatus.NEEDS_CHECK
+ assert doc.checks is not None
+ assert doc.checks.balance_identity_violations == [rows[BETA].row_number]
+ assert doc.checks.parent_sum_violations == [rows["60.01"].row_number]
+ assert doc.checks.total_row_matches_root is True
+ assert rows[BETA].closing_debit == D("65001.00")
+ texts = [w for w in warnings_of(outcome) if "§1.4" in w]
+ assert any(str(rows[BETA].row_number) in w for w in texts)
+ assert any(str(rows["60.01"].row_number) in w for w in texts)
+
+
+def test_s11_header_repeat_is_skipped(fixtures_dir: Path) -> None:
+ """«Повтор заголовка внутри данных» (печать по страницам): строки заголовка не данные, дерево не нарушено."""
+ outcome = parse_named(fixtures_dir, "s11_header_repeat.xlsx")
+ doc = one_document(outcome)
+
+ assert rows_text(doc) == [
+ "60",
+ "60.01",
+ ALFA,
+ BETA,
+ "Иванов Иван",
+ "60.02",
+ GAMMA,
+ "ООО Дельта",
+ "ИП Сидоров",
+ "Итого",
+ ]
+ rows = by_text(doc)
+ assert [rows[text].level for text in rows_text(doc)] == [0, 1, 2, 2, 2, 1, 2, 2, 2, 0]
+ assert rows["60.02"].parent_row_number == rows["60"].row_number
+ assert outcome.status is ResultStatus.DONE
+
+
+def test_s12_statement_sheet_is_skipped_with_warning(fixtures_dir: Path) -> None:
+ """«Лист ББ + лист ОСВ»: один документ, лист формы пропущен с предупреждением о подсрезе 2b."""
+ outcome = parse_named(fixtures_dir, "s12_statement_and_osv.xlsx")
+
+ (doc,) = documents(outcome)
+ assert doc.locator == "sheet:ОСВ 60"
+ assert any("Бухгалтерский баланс" in w and "skipped: statement" in w for w in warnings_of(outcome))
+ assert outcome.status is ResultStatus.DONE
+
+
+def test_s13_xlsb_equals_s02_except_locator_and_warnings(fixtures_dir: Path) -> None:
+ """«XLSB»: результат совпадает с xlsx-оригиналом (s02), кроме `locator` и предупреждения об outline."""
+ xlsb = parse_named(fixtures_dir, "s13_no_outline_indent.xlsb")
+ xlsx = parse_named(fixtures_dir, "s02_no_outline_indent.xlsx")
+
+ xlsb_doc, xlsx_doc = one_document(xlsb), one_document(xlsx)
+ assert xlsb_doc.model_copy(update={"locator": ""}) == xlsx_doc.model_copy(update={"locator": ""})
+ assert xlsb.status is xlsx.status is ResultStatus.DONE
+ assert XLSB_WARNING in warnings_of(xlsb)
+ assert [w for w in warnings_of(xlsb) if w != XLSB_WARNING] == warnings_of(xlsx)
+ assert any("hierarchy=indent" in w for w in warnings_of(xlsb))
+ assert xlsb.metrics is not None
+ assert xlsx.metrics is not None
+ assert xlsb.metrics.rows == xlsx.metrics.rows
+
+
+def test_s14_each_statement_form_is_recognised_by_its_own_marker(fixtures_dir: Path) -> None:
+ """Три листа форм (код ОКУД 0710001, «Отчет о финансовых результатах», код 0710002) пропущены, ОСВ разобран."""
+ outcome = parse_named(fixtures_dir, "s14_statement_forms.xlsx")
+
+ (doc,) = documents(outcome)
+ assert doc.locator == "sheet:ОСВ 60"
+ skipped = [w for w in warnings_of(outcome) if "skipped: statement" in w]
+ assert [w.split(" skipped")[0] for w in skipped] == ["sheet=Форма 1", "sheet=ОФР", "sheet=Форма 2"]
+ assert outcome.status is ResultStatus.DONE
+
+
+def test_s15_group_title_synonyms_find_all_six_sum_columns(fixtures_dir: Path) -> None:
+ """«Входящее/Конечное сальдо» (лист 60), «Начальное/Исходящее сальдо» (лист 62): шесть колонок, суммы верны."""
+ outcome = parse_named(fixtures_dir, "s15_group_title_synonyms.xlsx")
+
+ first, second = documents(outcome)
+ assert (first.locator, second.locator) == ("sheet:ОСВ 60", "sheet:ОСВ 62")
+ alfa = by_text(first)[ALFA]
+ assert sums(alfa) == (None, D("250000.50"), D("180000.25"), D("420000.00"), None, D("490000.25"))
+ buyer = by_text(second)["ООО Покупатель-1"]
+ assert sums(buyer) == (D("500000.00"), None, D("320000.00"), D("410000.00"), D("410000.00"), None)
+ assert outcome.status is ResultStatus.DONE
+
+
+def test_s16_header_without_organization_year_only_period_and_currency_note(fixtures_dir: Path) -> None:
+ """Шапка без организации (предупреждение), период «2024» -> Q4, валюта «RUB (код 643)» -> RUB, метки группировок
+ отдельными строками («Счет» среди них), «Детализация: …» без слова «субконто» и группировки вне ключевых."""
+ outcome = parse_named(fixtures_dir, "s16_header_synonyms.xlsx")
+
+ first, second = documents(outcome)
+ assert first.header.organization_name == ""
+ assert any("организации в шапке не найдено" in w for w in warnings_of(outcome))
+ assert (first.header.period_text, first.header.period_year, first.header.period_quarter) == ("2024", 2024, "Q4")
+ assert first.header.currency == "RUB"
+ assert [group.type for group in first.group_schema] == ["ACCOUNT", "COUNTERPARTY"]
+ assert second.header.header_variant == VARIANT_2
+ assert [(group.type, group.name) for group in second.group_schema] == [
+ ("ACCOUNT", "Субконто"),
+ ("OTHER", "Номенклатура"),
+ ("OTHER", "Статьи затрат"),
+ ("OTHER", "Нематериальные активы"),
+ ("OTHER", "Валюта"),
+ ]
+ assert outcome.status is ResultStatus.DONE
diff --git a/api/tests/synthetic/xlsb_writer.py b/api/tests/synthetic/xlsb_writer.py
new file mode 100644
index 0000000..7f4e4c5
--- /dev/null
+++ b/api/tests/synthetic/xlsb_writer.py
@@ -0,0 +1,191 @@
+"""Минимальный писатель XLSB (BIFF12) для тестов: значения ячеек и имена листов, без стилей и объединений.
+
+Это НЕ файл Excel: в книге нет `styles.bin`, свойств документа и прочего, что пишет Excel. Структура — ровно то, что
+нужно читателю `pyxlsb` (его использует `src.parser.readers.xlsb`): `[Content_Types].xml`, `_rels/.rels`,
+`xl/workbook.bin` (+ `.rels`), `xl/sharedStrings.bin`, `xl/worksheets/sheetN.bin`. Строки лежат в таблице общих
+строк (`BrtCellIsst`), числа — `BrtCellReal` (double). Запись детерминирована: порядок частей фиксирован, метки времени
+в zip постоянны.
+
+Назначение — получить `s13_no_outline_indent.xlsb` без Excel/LibreOffice (ruling R-9); совместимость с Excel не
+проверялась. Реальный xlsb, сохранённый Excel, остаётся нерешённым вопросом доверия к читателю (см. README fixtures).
+"""
+
+import io
+import struct
+import zipfile
+from collections.abc import Mapping, Sequence
+from decimal import Decimal
+
+__all__ = ["write_xlsb"]
+
+# Метка времени всех частей архива постоянна: файл байт в байт воспроизводим
+ZIP_DATE_TIME = (2026, 1, 1, 0, 0, 0)
+
+# Идентификаторы записей BIFF12 (MS-XLSB 2.3)
+_ROW_HDR = 0x0000
+_CELL_REAL = 0x0005
+_CELL_ISST = 0x0007
+_SST_ITEM = 0x0013
+_BEGIN_BOOK = 0x0183
+_END_BOOK = 0x0184
+_BEGIN_SHEET = 0x0181
+_END_SHEET = 0x0182
+_BEGIN_BUNDLE_SHS = 0x018F
+_END_BUNDLE_SHS = 0x0190
+_BUNDLE_SH = 0x019C
+_BEGIN_SHEET_DATA = 0x0191
+_END_SHEET_DATA = 0x0192
+_DIMENSION = 0x0194
+_BEGIN_SST = 0x019F
+_END_SST = 0x01A0
+
+_ID_SINGLE_BYTE_LIMIT = 0x80
+_LEN_CONTINUATION = 0x80
+_LEN_PAYLOAD_MASK = 0x7F
+_LEN_BITS = 7
+_ROW_HEIGHT = 300 # 15 pt в двадцатых долях пункта
+
+CellValue = str | int | Decimal | float
+SheetCells = Mapping[tuple[int, int], CellValue] # (строка, колонка) 1-based -> значение
+
+_CONTENT_TYPES = (
+ '\n'
+ ''
+ ''
+ ''
+ ''
+ "{sheets}"
+ ''
+ ""
+)
+_SHEET_OVERRIDE = ''
+_ROOT_RELS = (
+ '\n'
+ ''
+ ''
+ ""
+)
+_WORKBOOK_RELS = (
+ '\n'
+ ''
+ "{sheets}"
+ ''
+ ""
+)
+_SHEET_REL = (
+ ''
+)
+
+
+def _record(record_id: int, payload: bytes = b"") -> bytes:
+ """Запись BIFF12: идентификатор (1–2 байта), длина (7-битные группы), данные."""
+ head = bytes([record_id]) if record_id < _ID_SINGLE_BYTE_LIMIT else struct.pack(">= _LEN_BITS
+ length.append(chunk | (_LEN_CONTINUATION if size else 0))
+ if not size:
+ break
+ return head + bytes(length) + payload
+
+
+def _wide_string(text: str) -> bytes:
+ """XLWideString: число символов (uint32) и UTF-16LE."""
+ return struct.pack(" bytes:
+ body = [_record(_BEGIN_SST, struct.pack(" bytes:
+ body = [_record(_BEGIN_BOOK), _record(_BEGIN_BUNDLE_SHS)]
+ for number, name in enumerate(names, start=1):
+ # hsState (видимый), iTabID, strRelID, strName
+ body.append(
+ _record(_BUNDLE_SH, struct.pack(" float:
+ return float(value)
+
+
+def _worksheet(cells: SheetCells, string_ids: dict[str, int]) -> bytes:
+ rows: dict[int, dict[int, CellValue]] = {}
+ for (row, col), value in cells.items():
+ rows.setdefault(row, {})[col] = value
+ last_row = max(rows, default=1)
+ last_col = max((col for row in rows.values() for col in row), default=1)
+ body = [
+ _record(_BEGIN_SHEET),
+ # 0-based границы: строки, колонки
+ _record(_DIMENSION, struct.pack(" bytes:
+ """Книга XLSB: имя листа → ячейки `{(строка, колонка): значение}` (1-based; текст или число)."""
+ names = list(sheets)
+ strings: list[str] = []
+ string_ids: dict[str, int] = {}
+ total = 0
+ for cells in sheets.values():
+ for _, value in sorted(cells.items()):
+ if isinstance(value, str):
+ total += 1
+ if value not in string_ids:
+ string_ids[value] = len(strings)
+ strings.append(value)
+ parts: list[tuple[str, bytes]] = [
+ (
+ "[Content_Types].xml",
+ _CONTENT_TYPES.format(
+ sheets="".join(_SHEET_OVERRIDE.format(n=n) for n in range(1, len(names) + 1))
+ ).encode(),
+ ),
+ ("_rels/.rels", _ROOT_RELS.encode()),
+ (
+ "xl/_rels/workbook.bin.rels",
+ _WORKBOOK_RELS.format(
+ sheets="".join(_SHEET_REL.format(n=n) for n in range(1, len(names) + 1)), strings=len(names) + 1
+ ).encode(),
+ ),
+ ("xl/workbook.bin", _workbook(names)),
+ ("xl/sharedStrings.bin", _shared_strings(strings, total)),
+ ]
+ parts += [(f"xl/worksheets/sheet{n}.bin", _worksheet(sheets[name], string_ids)) for n, name in enumerate(names, 1)]
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
+ for name, data in parts:
+ info = zipfile.ZipInfo(name, date_time=ZIP_DATE_TIME)
+ info.compress_type = zipfile.ZIP_DEFLATED
+ archive.writestr(info, data)
+ return buffer.getvalue()
diff --git a/api/tests/unit/parser/gridbuilder.py b/api/tests/unit/parser/gridbuilder.py
new file mode 100644
index 0000000..d6da674
--- /dev/null
+++ b/api/tests/unit/parser/gridbuilder.py
@@ -0,0 +1,149 @@
+"""Сборка `Sheet` из текстовой «картинки» для юнит-тестов детекторов, шапки, иерархии и классификации.
+
+Лист описывается списком строк сверху вниз (первый элемент — строка 1), строка — списком ячеек слева направо (первый
+элемент — колонка 1). Так тест читается как сама ведомость:
+
+ sheet = sheet_from_rows(
+ [
+ ["ООО Ромашка"],
+ ["Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."],
+ [],
+ [M("Счет", 2), M("Сальдо на начало", 2), M("Обороты за период", 2), M("Сальдо на конец", 2)],
+ [M("Контрагенты", 2), "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ [L(0), M("60", 2), 100, None, 50, 30, 120],
+ [L(1), M("ООО Поставщик", 2), C("100", bold=True)],
+ ]
+ )
+
+Значения ячеек:
+- `None` или `""` — пустая ячейка (в `Sheet.cells` не попадает);
+- `str` — текстовая ячейка как есть, **включая строку из пробелов** (`" "`): так тесты проверяют, что детекторы
+ считают её пустой, даже если читатель её сохранил (Review Focus 5); текст «1 234,00» остаётся текстом;
+- `int` и `Decimal` — числовая ячейка (`Decimal`); `float` запрещён (деньги — `Decimal`, Global Constraints);
+- `datetime.date` — дата, `bool` — логическое;
+- `C(value, bold=..., indent=...)` — ячейка с оформлением (жирность, отступ) для каскада иерархии;
+- `M(value, width, height=1)` — объединённая ячейка: значение в левой верхней, диапазон `width` колонок вправо и
+ `height` строк вниз. В своей строке `M` занимает `width` позиций — следующий элемент списка идёт в колонку правее
+ диапазона. В нижних строках диапазона на месте покрытых колонок пишите `None` (непустое значение там — ошибка
+ сборки: в Excel под merged-диапазоном значений нет);
+- `L(level)` — только первым элементом строки: outline level Excel этой строки (0 — без группировки); сам `L`
+ колонку не занимает.
+
+Разреженная строка — `at({1: "60", 5: 100, 17: 120})`: значения по номерам колонок, остальное пусто (удобно, когда
+суммы стоят в далёких колонках). Её можно предварить уровнем: `[L(1), *at({...})]`.
+
+`n_rows`/`n_cols` — по длине списка строк и самой длинной строке (с учётом ширины `M`), как габариты листа у читателя.
+"""
+
+import datetime
+from collections.abc import Sequence
+from dataclasses import dataclass
+from decimal import Decimal
+
+from src.parser.grid import Cell, MergedRange, Sheet
+
+__all__ = ["C", "L", "M", "at", "sheet_from_rows"]
+
+DEFAULT_SHEET_NAME = "Лист_1"
+
+
+@dataclass(frozen=True)
+class C:
+ """Ячейка с оформлением: жирность и отступ (`alignment.indent` или ведущие пробелы у читателя)."""
+
+ value: object
+ bold: bool = False
+ indent: int = 0
+
+
+@dataclass(frozen=True)
+class M:
+ """Объединённая ячейка: значение (или `C`) и размер диапазона в колонках и строках."""
+
+ value: object
+ width: int
+ height: int = 1
+
+ def __post_init__(self) -> None:
+ if self.width < 1 or self.height < 1:
+ raise ValueError(f"размер merged-диапазона должен быть ≥ 1, получено {self.width}x{self.height}")
+
+
+@dataclass(frozen=True)
+class L:
+ """Outline level строки (первый элемент строки)."""
+
+ level: int
+
+
+def at(values: dict[int, object]) -> list[object]:
+ """Строка по номерам колонок (1-based): `at({1: "60", 5: 100})` == `["60", None, None, None, 100]`."""
+ if not values:
+ return []
+ if min(values) < 1:
+ raise ValueError("номера колонок 1-based")
+ return [values.get(col) for col in range(1, max(values) + 1)]
+
+
+def _make_cell(value: object) -> Cell | None:
+ bold, indent = False, 0
+ if isinstance(value, C):
+ value, bold, indent = value.value, value.bold, value.indent
+ if value is None or value == "":
+ return None
+ if isinstance(value, bool):
+ return Cell(value, "bool", bold=bold, indent=indent)
+ if isinstance(value, float):
+ raise TypeError(f"float {value!r} в тестовой сетке: используйте int или Decimal")
+ if isinstance(value, int | Decimal):
+ return Cell(Decimal(value), "number", bold=bold, indent=indent)
+ if isinstance(value, datetime.date):
+ return Cell(value, "date", bold=bold, indent=indent)
+ if isinstance(value, str):
+ return Cell(value, "text", bold=bold, indent=indent)
+ raise TypeError(f"неподдерживаемое значение ячейки {value!r}")
+
+
+def sheet_from_rows(rows: Sequence[Sequence[object]], name: str = DEFAULT_SHEET_NAME) -> Sheet:
+ """`Sheet` по «картинке» строк (см. docstring модуля); номера строк и колонок — 1-based, как в Excel."""
+ cells: dict[tuple[int, int], Cell] = {}
+ merged: list[MergedRange] = []
+ covered: set[tuple[int, int]] = set() # ячейки под merged-диапазонами, кроме левой верхней
+ outline: dict[int, int] = {}
+ n_cols = 0
+ for row_number, row_items in enumerate(rows, start=1):
+ items = list(row_items)
+ if items and isinstance(items[0], L):
+ if items[0].level:
+ outline[row_number] = items[0].level
+ items = items[1:]
+ col = 1
+ for item in items:
+ if isinstance(item, L):
+ raise ValueError(f"строка {row_number}: L(level) допустим только первым элементом")
+ value, width, height = (item.value, item.width, item.height) if isinstance(item, M) else (item, 1, 1)
+ cell = _make_cell(value)
+ if (row_number, col) in covered:
+ if cell is not None:
+ raise ValueError(f"ячейка ({row_number}, {col}) лежит под merged-диапазоном, значение недопустимо")
+ elif cell is not None:
+ cells[(row_number, col)] = cell
+ if isinstance(item, M) and item.width * item.height > 1:
+ span = MergedRange(row_number, col, row_number + height - 1, col + width - 1)
+ merged.append(span)
+ covered.update(
+ (r, c)
+ for r in range(span.top, span.bottom + 1)
+ for c in range(span.left, span.right + 1)
+ if (r, c) != (span.top, span.left)
+ )
+ col += width
+ n_cols = max(n_cols, col - 1)
+ return Sheet(
+ name=name,
+ cells=cells,
+ merged=tuple(merged),
+ row_outline=outline,
+ n_rows=len(rows),
+ n_cols=n_cols,
+ )
diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py
new file mode 100644
index 0000000..5910693
--- /dev/null
+++ b/api/tests/unit/parser/test_checks.py
@@ -0,0 +1,281 @@
+"""Контрольные суммы §1.4 (spec «Контрольные суммы определяют статус», `05-domain-rules.md` §1.4, R-13, R-20).
+
+Строки собираются напрямую (`ClassifiedRow` над `BodyRow`): проверки — чистые функции над деревом строк, им не нужна
+книга. Номера строк произвольные; суммы — `Decimal` или `None` (пусто = 0).
+"""
+
+from decimal import Decimal
+
+from src.contract.common import DataType
+from src.parser.checks import TOLERANCE, balance_identity, parent_sums, run_checks, total_matches_root
+from src.parser.classify import ClassifiedRow, RowGroupType
+from src.parser.hierarchy import BodyRow
+
+Sums = tuple[str | None, str | None, str | None, str | None, str | None, str | None]
+EMPTY: Sums = (None, None, None, None, None, None)
+
+
+def row( # noqa: PLR0913 - строка теста описывается полностью одним вызовом
+ number: int,
+ sums: Sums,
+ *,
+ level: int = 0,
+ parent: int | None = None,
+ group_type: RowGroupType = "COUNTERPARTY",
+ data_type: DataType | None = "BU",
+ text: str = "строка",
+) -> ClassifiedRow:
+ body = BodyRow(
+ row=number,
+ text=text,
+ is_account=group_type == "ACCOUNT",
+ account_rank=1 if group_type == "ACCOUNT" else None,
+ bold=False,
+ indent=0,
+ outline=level,
+ sums=tuple(None if value is None else Decimal(value) for value in sums),
+ )
+ return ClassifiedRow(
+ body=body,
+ level=level,
+ parent=parent,
+ group_type=group_type,
+ group_name=None,
+ account_code_raw="60",
+ counterparty_name=None,
+ counterparty_inn=None,
+ data_type=data_type,
+ extra=None,
+ )
+
+
+def account(number: int, sums: Sums) -> ClassifiedRow:
+ return row(number, sums, group_type="ACCOUNT", text="60")
+
+
+def total(number: int, sums: Sums) -> ClassifiedRow:
+ return row(number, sums, group_type="TOTAL", text="Итого")
+
+
+# Корень 60 с двумя контрагентами, «Итого» равно корню; тождество выполняется во всех строках
+ROOT: Sums = ("100", None, "50", "30", "120", None)
+CHILD_A: Sums = ("60", None, "20", "10", "70", None)
+CHILD_B: Sums = ("40", None, "30", "20", "50", None)
+
+
+def tree() -> list[ClassifiedRow]:
+ return [
+ account(7, ROOT),
+ row(8, CHILD_A, level=1, parent=7),
+ row(9, CHILD_B, level=1, parent=7),
+ total(10, ROOT),
+ ]
+
+
+def test_tolerance_is_one_kopeck() -> None:
+ assert Decimal("0.01") == TOLERANCE
+
+
+def test_consistent_tree_has_no_violations() -> None:
+ checks, warnings = run_checks(tree())
+
+ assert checks.total_row_matches_root is True
+ assert checks.balance_identity_violations == []
+ assert checks.parent_sum_violations == []
+ assert warnings == []
+
+
+def test_balance_identity_treats_null_as_zero() -> None:
+ rows = [
+ row(1, (None, None, None, None, None, None)),
+ row(2, (None, "5", None, None, None, "5")), # только кредит: −5 = −5
+ row(3, ("10", None, None, "10", None, None)), # 10 − 10 = 0 при пустом конце
+ ]
+
+ assert balance_identity(rows) == []
+
+
+def test_balance_identity_reports_row_numbers_beyond_tolerance() -> None:
+ rows = [
+ row(1, ("100", None, "50", "30", "120.01", None)), # в пределах ±0.01
+ row(2, ("100", None, "50", "30", "121", None)), # конец увеличен на 1
+ row(3, (None, None, None, None, None, "0.02")),
+ ]
+
+ assert balance_identity(rows) == [2, 3]
+
+
+def test_parent_differs_from_sum_of_children() -> None:
+ rows = tree()
+ rows[2] = row(9, ("40", None, "30", "20", "51", None), level=1, parent=7)
+
+ assert parent_sums(rows) == [7]
+
+
+def test_parent_sum_is_checked_per_column_with_tolerance() -> None:
+ rows = [
+ account(7, ("100", None, "50", "30", "120", None)),
+ row(8, ("99.995", None, "50", "30", "119.995", None), level=1, parent=7),
+ ]
+
+ assert parent_sums(rows) == []
+
+
+def test_collapsed_parent_balance_is_compared_by_net() -> None:
+ """Счёт со свёрнутым сальдо (одна сторона), субсчета — с развёрнутым: сверка сальдо по нетто (как в 1С на всех
+ реальных файлах с субсчетами разных сторон); обороты — по колонкам.
+ """
+ rows = [
+ account(12, ("16", None, "134", "111", "39", None)),
+ row(13, ("40", None, "131", "111", "60", None), level=1, parent=12, group_type="ACCOUNT"),
+ row(22, (None, "24", "3", None, None, "21"), level=1, parent=12, group_type="ACCOUNT"),
+ ]
+
+ assert parent_sums(rows) == []
+
+
+def test_collapsed_parent_with_wrong_net_is_violation() -> None:
+ rows = [
+ account(12, ("17", None, "134", "111", "39", None)),
+ row(13, ("40", None, "131", "111", "60", None), level=1, parent=12, group_type="ACCOUNT"),
+ row(22, (None, "24", "3", None, None, "21"), level=1, parent=12, group_type="ACCOUNT"),
+ ]
+
+ assert parent_sums(rows) == [12]
+
+
+def test_collapsed_parent_on_the_opposite_side_is_violation() -> None:
+ """T9.1 (R-21): свёрнутое сальдо родителя на противоположной стороне (16 Кт при нетто детей +16 Дт) — нарушение:
+ нетто сравнивается со знаком, совпадения модуля мало."""
+ rows = [
+ account(12, (None, "16", "134", "111", "39", None)),
+ row(13, ("40", None, "131", "111", "60", None), level=1, parent=12, group_type="ACCOUNT"),
+ row(22, (None, "24", "3", None, None, "21"), level=1, parent=12, group_type="ACCOUNT"),
+ ]
+
+ assert parent_sums(rows) == [12]
+
+
+def test_zero_parent_with_children_netting_to_zero_is_accepted() -> None:
+ """T9.1 (R-21, осознанное поведение): родитель (0, 0) при детях (X Дт, X Кт) — без нарушения: нулевое сальдо
+ родителя — свёрнутое (не больше одной ненулевой стороны), нетто детей X − X = 0. Остаточный риск описан в спеке
+ (§1.4): потерянная пара сальдо у родителя этой проверкой не ловится."""
+ rows = [
+ account(12, ("0", "0", "134", "111", "23", None)),
+ row(13, ("50", None, "131", "111", "70", None), level=1, parent=12, group_type="ACCOUNT"),
+ row(22, (None, "50", "3", None, None, "47"), level=1, parent=12, group_type="ACCOUNT"),
+ ]
+
+ assert balance_identity(rows) == []
+ assert parent_sums(rows) == []
+
+
+def test_expanded_parent_balance_must_match_by_columns() -> None:
+ """Родитель с обеими сторонами сальдо — развёрнутое сальдо: совпадение одного нетто недостаточно."""
+ rows = [
+ account(12, ("41", "25", "134", "111", "60", "21")),
+ row(13, ("40", None, "131", "111", "60", None), level=1, parent=12, group_type="ACCOUNT"),
+ row(22, (None, "24", "3", None, None, "21"), level=1, parent=12, group_type="ACCOUNT"),
+ ]
+
+ assert parent_sums(rows) == [12]
+
+
+def test_turnovers_are_compared_by_columns_even_when_net_matches() -> None:
+ rows = [
+ account(12, ("16", None, "135", "112", "39", None)),
+ row(13, ("40", None, "131", "111", "60", None), level=1, parent=12, group_type="ACCOUNT"),
+ row(22, (None, "24", "3", None, None, "21"), level=1, parent=12, group_type="ACCOUNT"),
+ ]
+
+ assert parent_sums(rows) == [12]
+
+
+def test_currency_and_quantity_children_are_excluded() -> None:
+ rows = [
+ account(7, ROOT),
+ row(8, ROOT, level=1, parent=7),
+ row(9, ("1", None, "2", None, "3", None), level=2, parent=8, group_type="OTHER", data_type="CURRENCY"),
+ row(10, ("7", None, None, None, "7", None), level=2, parent=8, group_type="OTHER", data_type="QUANTITY"),
+ total(11, ROOT),
+ ]
+
+ assert parent_sums(rows) == []
+
+
+def test_children_of_other_data_type_are_not_summed() -> None:
+ """Σ детей «того же data_type»: ребёнок НУ под родителем БУ в сумму не входит."""
+ rows = [
+ account(7, ("10", None, None, None, "10", None)),
+ row(8, ("10", None, None, None, "10", None), level=1, parent=7),
+ row(9, ("5", None, None, None, "5", None), level=1, parent=7, data_type="NU"),
+ ]
+
+ assert parent_sums(rows) == []
+
+
+def test_rows_without_children_are_not_parents() -> None:
+ rows = [account(7, ROOT), account(8, ("1", None, None, None, "1", None))]
+
+ assert parent_sums(rows) == []
+
+
+def test_total_matches_first_root_account() -> None:
+ rows = tree()
+
+ assert total_matches_root(rows) is True
+ rows[-1] = total(10, ("100", None, "50", "30", "121", None))
+ assert total_matches_root(rows) is False
+
+
+def test_empty_osv_without_root_matches_when_total_is_empty_or_zero() -> None:
+ assert total_matches_root([total(8, EMPTY)]) is True
+ assert total_matches_root([total(8, ("0", None, "0.00", None, None, "0"))]) is True
+ assert total_matches_root([total(8, ("1", None, None, None, "1", None))]) is False
+
+
+def test_missing_total_fails_root_check_with_warning() -> None:
+ """R-20: без «Итого» сверку выполнить нельзя — `false` и предупреждение."""
+ rows = tree()[:-1]
+
+ checks, warnings = run_checks(rows)
+
+ assert total_matches_root(rows) is False
+ assert checks.total_row_matches_root is False
+ assert "строка «Итого» не найдена" in warnings
+
+
+def test_run_checks_warnings_name_row_numbers() -> None:
+ rows = tree()
+ rows[2] = row(9, ("40", None, "30", "20", "51", None), level=1, parent=7)
+
+ checks, warnings = run_checks(rows)
+
+ assert checks.balance_identity_violations == [9]
+ assert checks.parent_sum_violations == [7]
+ assert checks.total_row_matches_root is True
+ assert any("строки 9" in warning for warning in warnings), warnings
+ assert any("строки 7" in warning for warning in warnings), warnings
+
+
+def test_run_checks_reports_total_mismatch_with_row_numbers() -> None:
+ rows = tree()
+ rows[-1] = total(10, ("100", None, "50", "30", "121", None))
+
+ checks, warnings = run_checks(rows)
+
+ assert checks.total_row_matches_root is False
+ assert any("10" in warning and "7" in warning and "Итого" in warning for warning in warnings), warnings
+
+
+def test_run_checks_warning_lists_are_capped() -> None:
+ """Список нарушений в `checks` полный, текст предупреждения — первые номера и общее число."""
+ broken: Sums = ("1", None, None, None, "2", None)
+ rows = [row(number, broken) for number in range(1, 101)]
+
+ checks, warnings = run_checks(rows)
+
+ assert checks.balance_identity_violations == list(range(1, 101))
+ (identity,) = [warning for warning in warnings if "сальдо" in warning]
+ assert "всего 100" in identity
+ assert "100," not in identity
diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py
new file mode 100644
index 0000000..e310b52
--- /dev/null
+++ b/api/tests/unit/parser/test_classify.py
@@ -0,0 +1,725 @@
+"""Классификация строк ОСВ (spec «Классификация строк», design D-4, `05-domain-rules.md` §1.2–§1.4).
+
+Сетки собираются `gridbuilder`; конвейер теста — тот же, что у парсера: заголовок таблицы → схема группировок →
+`collect_body_rows` → каскад иерархии → `classify_rows`. Позиции колонок в сетках произвольные: классификатор знает их
+только из `TableHeader`.
+"""
+
+from dataclasses import dataclass
+from decimal import Decimal
+
+import pytest
+from gridbuilder import C, L, M, sheet_from_rows
+
+from src.contract.common import DataType
+from src.parser.classify import ACCOUNT_RE, ClassifiedRow, classify_rows, collect_body_rows
+from src.parser.deadline import Deadline
+from src.parser.detect import find_table_header
+from src.parser.errors import ParseTimeoutError
+from src.parser.grid import Sheet
+from src.parser.header import build_group_schema
+from src.parser.hierarchy import TRIVIAL_STRATEGY, Levels, resolve_levels
+from src.parser.rules import Rules, load_rules
+
+TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
+OPENING = "Сальдо на начало периода"
+TURNOVER = "Обороты за период"
+CLOSING = "Сальдо на конец периода"
+TIMEOUT_SECONDS = 60
+EXPIRED_AFTER_SECONDS = 1000
+ACCOUNT_ROW = 7 # первая строка данных в сетке `osv(...)`: шапка 3 строки + блок заголовка 3 строки
+DUPLICATED_EXTRA_COLUMNS = 2
+
+Sums = tuple[object, object, object, object, object, object]
+NO_SUMS: Sums = (None, None, None, None, None, None)
+
+
+@pytest.fixture(scope="module")
+def rules() -> Rules:
+ return load_rules()
+
+
+@dataclass(frozen=True)
+class Parsed:
+ rows: list[ClassifiedRow]
+ warnings: list[str]
+ levels: Levels
+ total_row: int | None
+
+ def by_text(self, text: str) -> list[ClassifiedRow]:
+ return [row for row in self.rows if row.body.text == text]
+
+ def one(self, text: str) -> ClassifiedRow:
+ found = self.by_text(text)
+ assert len(found) == 1, (text, [row.body.text for row in self.rows])
+ return found[0]
+
+
+def parse(
+ sheet: Sheet, rules: Rules, doc_data_type: DataType | None = "BU", deadline: Deadline | None = None
+) -> Parsed:
+ deadline = deadline or Deadline(TIMEOUT_SECONDS)
+ header = find_table_header(sheet, rules)
+ schema, _ = build_group_schema(sheet, header, rules)
+ body, total_row, warnings = collect_body_rows(sheet, header, rules, deadline)
+ levels = resolve_levels(body, total_row, deadline)
+ rows = classify_rows(body, levels, schema, header, sheet, doc_data_type, total_row, rules)
+ return Parsed(rows, warnings, levels, total_row)
+
+
+# --- Сетка: «Счет | ИНН | КПП | начало Дт/Кт | обороты Дт/Кт | конец Дт/Кт», группировки Счет/Контрагенты/Договоры ---
+
+
+def _head() -> list[list[object]]:
+ return [
+ ["ООО Ромашка"],
+ [TITLE],
+ [],
+ ["Счет", "ИНН", "КПП", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 4
+ ["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], # 5
+ ["Договоры"], # 6
+ ]
+
+
+def r(level: int | None, text: object, sums: Sums = NO_SUMS, inn: object = None, kpp: object = None) -> list[object]:
+ """Строка данных: outline `level` (`None` — без группировки Excel), текст, ИНН, КПП и шесть сумм."""
+ cells: list[object] = [text, inn, kpp, *sums]
+ return cells if level is None else [L(level), *cells]
+
+
+def osv(*rows: list[object]) -> Sheet:
+ return sheet_from_rows([*_head(), *rows])
+
+
+S100: Sums = (100, None, 50, 30, 120, None)
+
+
+def _basic() -> Sheet:
+ return osv(
+ r(0, "60", S100), # 7
+ r(1, "60.01", S100), # 8
+ r(2, "ООО Поставщик", S100, inn="7701234567", kpp="770101001"), # 9
+ r(3, "Договор № 1 от 01.02.2025", S100), # 10
+ r(None, "Итого", S100), # 11
+ )
+
+
+# --- Регулярка счёта ---
+
+
+@pytest.mark.parametrize(
+ ("text", "code"),
+ [
+ ("60", "60"),
+ ("60.01", "60.01"),
+ ("76.07.1", "76.07.1"),
+ ("001", "001"),
+ ("60.1", "60.1"),
+ ("60.01, Расчеты с поставщиками", "60.01"),
+ # R-16: буквенные субсчета плана счетов 1С (НДС по авансам и т. п.)
+ ("76.АВ", "76.АВ"),
+ ("76.ВА", "76.ВА"),
+ ("76.ОТ", "76.ОТ"),
+ ("76.АВ, НДС по авансам и предоплатам", "76.АВ"),
+ ("76.АВ.1", "76.АВ.1"),
+ ("19.ЁЖ", "19.ЁЖ"),
+ ("60.AB", "60.AB"),
+ ],
+)
+def test_account_re_matches_account_codes(text: str, code: str) -> None:
+ found = ACCOUNT_RE.match(text)
+
+ assert found is not None
+ assert found.group(1) == code
+
+
+def test_account_re_tolerates_no_space_after_comma() -> None:
+ """R-18: намеренный допуск шире текста спеки (там после запятой обязателен пробел): «60,Расчеты» — счёт 60."""
+ found = ACCOUNT_RE.match("60,Расчеты")
+
+ assert found is not None
+ assert found.group(1) == "60"
+
+
+@pytest.mark.parametrize(
+ "text",
+ [
+ "6",
+ "6001",
+ "60.001",
+ "60.01.1.1",
+ "ООО 60",
+ "60 лет Октября",
+ "Итого",
+ "12.2025 г.",
+ "76.АВГД", # R-16: буквенный сегмент — не длиннее трёх букв
+ "76.ав", # только прописные: без IGNORECASE
+ "76.Ав",
+ "76.А1",
+ ],
+)
+def test_account_re_rejects_non_accounts(text: str) -> None:
+ assert ACCOUNT_RE.match(text) is None
+
+
+def test_account_re_residual_false_positive_documented() -> None:
+ """Известный остаточный ложноположительный случай R-16: «12.ООО» (две цифры, точка, до трёх прописных букв)
+ принимается за счёт; описан в docstring модуля `classify`.
+ """
+ assert ACCOUNT_RE.match("12.ООО") is not None
+
+
+def test_account_re_accepts_multiline_name_after_comma() -> None:
+ """M-1: многострочная ячейка «60.01, Расчеты\nс поставщиками» — счёт 60.01 (re.DOTALL), а не контрагент."""
+ found = ACCOUNT_RE.match("60.01, Расчеты\nс поставщиками")
+
+ assert found is not None
+ assert found.group(1) == "60.01"
+ assert found.group(2) == "Расчеты\nс поставщиками"
+
+
+@pytest.mark.parametrize("text", ["٦٠.01", "60.٠١", "60", "60.01\nРасчеты"])
+def test_account_re_accepts_only_ascii_digits(text: str) -> None:
+ """M-1: re.ASCII — `\\d` только 0–9 (арабско-индийские и полноширинные цифры — не код счёта); без запятой
+ перевод строки после кода не делает строку счётом."""
+ assert ACCOUNT_RE.match(text) is None
+
+
+def test_multiline_account_row_keeps_children_group_types(rules: Rules) -> None:
+ """M-1: многострочное имя субсчёта не сдвигает group_type детей (контрагент остаётся контрагентом)."""
+ sheet = osv(
+ r(0, "60", S100),
+ r(1, "60.01, Расчеты\nс поставщиками", S100),
+ r(2, "ООО Поставщик", S100, inn="7701234567"),
+ r(None, "Итого", S100),
+ )
+
+ parsed = parse(sheet, rules)
+
+ subaccount = parsed.one("60.01, Расчеты\nс поставщиками")
+ assert (subaccount.group_type, subaccount.account_code_raw, subaccount.body.account_rank) == ("ACCOUNT", "60.01", 2)
+ assert parsed.one("ООО Поставщик").group_type == "COUNTERPARTY"
+
+
+def test_letter_subaccount_is_account_with_children(rules: Rules) -> None:
+ """R-16: «76.АВ» — субсчёт (ранг 2, сосед «76.04»), его дети — по схеме: контрагент, договор."""
+ sheet = osv(
+ r(0, "76", S100),
+ r(1, "76.04", S100),
+ r(2, "ООО Первый", S100, inn="7701234567"),
+ r(1, "76.АВ, НДС по авансам", S100), # 10
+ r(2, "Русский бисквит", S100, inn="3528052718"), # 11
+ r(3, "Договор № 5", S100),
+ r(None, "Итого", S100),
+ )
+
+ parsed = parse(sheet, rules)
+
+ letter = parsed.one("76.АВ, НДС по авансам")
+ assert (letter.group_type, letter.account_code_raw, letter.body.account_rank) == ("ACCOUNT", "76.АВ", 2)
+ counterparty = parsed.one("Русский бисквит")
+ assert (counterparty.group_type, counterparty.account_code_raw, counterparty.parent) == (
+ "COUNTERPARTY",
+ "76.АВ",
+ letter.body.row,
+ )
+ assert counterparty.counterparty_inn == "3528052718"
+ assert parsed.one("Договор № 5").group_type == "CONTRACT"
+
+
+def test_letter_subaccount_is_sibling_in_account_pattern(rules: Rules) -> None:
+ """R-16: без outline и отступов (стратегия account_pattern) «76.АВ» — сосед «76.04», а не его ребёнок."""
+ sheet = osv(
+ r(None, "76", S100), # 7
+ r(None, "76.04", S100), # 8
+ r(None, "ООО Первый", S100), # 9
+ r(None, "76.АВ", S100), # 10
+ r(None, "Русский бисквит", S100), # 11
+ r(None, "Итого", S100),
+ )
+
+ parsed = parse(sheet, rules)
+
+ assert parsed.levels.strategy == "account_pattern"
+ sub_04, letter = parsed.one("76.04"), parsed.one("76.АВ")
+ assert (letter.level, letter.parent) == (sub_04.level, sub_04.parent) == (1, ACCOUNT_ROW)
+ assert parsed.one("Русский бисквит").parent == letter.body.row
+ assert parsed.one("Русский бисквит").group_type == "COUNTERPARTY"
+
+
+# --- Группы по позиции в схеме ---
+
+
+def test_account_subaccount_counterparty_contract(rules: Rules) -> None:
+ parsed = parse(_basic(), rules)
+
+ kinds = [(row.body.text, row.group_type, row.group_name, row.account_code_raw, row.level) for row in parsed.rows]
+ assert kinds == [
+ ("60", "ACCOUNT", "Счет", "60", 0),
+ ("60.01", "ACCOUNT", "Счет", "60.01", 1),
+ ("ООО Поставщик", "COUNTERPARTY", "Контрагенты", "60.01", 2), # schema[1]
+ ("Договор № 1 от 01.02.2025", "CONTRACT", "Договоры", "60.01", 3), # schema[2]
+ ("Итого", "TOTAL", None, None, 0),
+ ]
+ assert parsed.levels.strategy == "outline"
+
+
+def test_parents_follow_tree(rules: Rules) -> None:
+ parsed = parse(_basic(), rules)
+
+ assert [row.parent for row in parsed.rows] == [None, 7, 8, 9, None]
+
+
+def test_beyond_schema_is_other(rules: Rules) -> None:
+ sheet = osv(
+ r(0, "60", S100),
+ r(1, "ООО Поставщик", S100, inn="7701234567"),
+ r(2, "Договор № 1", S100),
+ r(3, "Спецификация 7", S100),
+ r(None, "Итого", S100),
+ )
+
+ parsed = parse(sheet, rules)
+
+ beyond = parsed.one("Спецификация 7")
+ assert (beyond.group_type, beyond.group_name, beyond.account_code_raw) == ("OTHER", None, "60")
+ assert parsed.one("ООО Поставщик").group_type == "COUNTERPARTY"
+ assert parsed.one("Договор № 1").group_type == "CONTRACT"
+
+
+def test_account_with_name_after_comma(rules: Rules) -> None:
+ sheet = osv(
+ r(0, "60", S100),
+ r(1, "60.01, Расчеты с поставщиками", S100),
+ r(2, "ООО Поставщик", S100),
+ r(None, "Итого", S100),
+ )
+
+ parsed = parse(sheet, rules)
+
+ account = parsed.one("60.01, Расчеты с поставщиками")
+ assert (account.group_type, account.account_code_raw) == ("ACCOUNT", "60.01")
+ assert account.body.text == "60.01, Расчеты с поставщиками" # row_value — исходный текст
+ assert parsed.one("ООО Поставщик").account_code_raw == "60.01"
+
+
+def test_same_counterparty_under_different_subaccounts(rules: Rules) -> None:
+ """Review Focus 3: одноимённые контрагенты различаются родителем и счётом ветки, а не именем."""
+ sheet = osv(
+ r(0, "66", S100),
+ r(1, "66.03", (None, None, 40, 40, None, None)), # 8
+ r(2, "Юг-Финанс", (None, None, 40, 40, None, None)), # 9
+ r(1, "66.04", (100, None, 10, -10, 120, None)), # 10
+ r(2, "Юг-Финанс", (100, None, 10, -10, 120, None)), # 11
+ r(None, "Итого", S100),
+ )
+
+ parsed = parse(sheet, rules)
+
+ first, second = parsed.by_text("Юг-Финанс")
+ assert (first.parent, first.account_code_raw) == (8, "66.03")
+ assert (second.parent, second.account_code_raw) == (10, "66.04")
+
+
+# --- Контрагент и ИНН ---
+
+
+@pytest.mark.parametrize(
+ ("inn", "expected"),
+ [
+ ("7701234567", "7701234567"),
+ ("ИНН 770123456789", "770123456789"),
+ (Decimal("123456789"), "0123456789"), # число с потерянным ведущим нулём
+ ("№ 12 от 01.02.2025", None),
+ ("12345", None),
+ (None, None),
+ ],
+)
+def test_counterparty_inn_from_inn_column(rules: Rules, inn: object, expected: str | None) -> None:
+ sheet = osv(
+ r(0, "60", S100),
+ r(1, "ООО Поставщик", S100, inn=inn),
+ r(1, "ООО Второй", (None, None, None, None, None, None), inn="7702222222"),
+ r(1, "ООО Третий", (None, None, None, None, None, None), inn="7703333333"),
+ r(None, "Итого", S100),
+ )
+
+ parsed = parse(sheet, rules)
+
+ counterparty = parsed.one("ООО Поставщик")
+ assert counterparty.group_type == "COUNTERPARTY"
+ assert counterparty.counterparty_name == "ООО Поставщик"
+ assert counterparty.counterparty_inn == expected
+
+
+def test_counterparty_fields_only_on_counterparty_rows(rules: Rules) -> None:
+ parsed = parse(_basic(), rules)
+
+ assert [(row.counterparty_name, row.counterparty_inn) for row in parsed.rows] == [
+ (None, None),
+ (None, None),
+ ("ООО Поставщик", "7701234567"),
+ (None, None),
+ (None, None),
+ ]
+
+
+# --- data_type строк ---
+
+
+def test_currency_and_quantity_rows(rules: Rules) -> None:
+ sheet = osv(
+ r(0, "60", S100),
+ r(1, "ООО Поставщик", S100, inn="7701234567"),
+ r(2, "USD", (5, None, 1, None, 6, None)),
+ r(2, "Кол.", (3, None, None, None, 3, None)),
+ r(2, "Вал.", (5, None, 1, None, 6, None)),
+ r(2, "eur", (5, None, 1, None, 6, None)),
+ r(None, "Итого", S100),
+ )
+
+ parsed = parse(sheet, rules, doc_data_type="NU")
+
+ types = {row.body.text: row.data_type for row in parsed.rows}
+ assert types == {
+ "60": "NU",
+ "ООО Поставщик": "NU",
+ "USD": "CURRENCY",
+ "Кол.": "QUANTITY",
+ "Вал.": "CURRENCY",
+ "eur": "CURRENCY",
+ "Итого": "NU",
+ }
+ usd = parsed.one("USD")
+ assert usd.body.sums[0] == Decimal("5.00") # суммы остаются в шести полях
+ # Строка-показатель — не группировка: OTHER, а не следующая группа схемы (CONTRACT)
+ assert (usd.group_type, usd.group_name, usd.account_code_raw) == ("OTHER", None, "60")
+
+
+def test_doc_data_type_none_passes_through(rules: Rules) -> None:
+ parsed = parse(_basic(), rules, doc_data_type=None)
+
+ assert {row.data_type for row in parsed.rows} == {None}
+
+
+# --- extra ---
+
+
+def test_extra_from_unmatched_columns(rules: Rules) -> None:
+ parsed = parse(_basic(), rules)
+
+ assert parsed.one("ООО Поставщик").extra == {"КПП": "770101001"}
+ assert parsed.one("60").extra is None # все значения пусты
+ assert parsed.one("Итого").extra is None
+
+
+def test_account_row_without_sums_and_duplicate_extra_titles(rules: Rules) -> None:
+ """Review Focus 1: строка счёта со всеми null не падает; две колонки с одним заголовком — разные ключи extra."""
+ sheet = sheet_from_rows(
+ [
+ ["ООО Ромашка"],
+ [TITLE],
+ [],
+ ["Счет", "Кол-во", "Кол-во", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
+ ["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ [L(0), "60"], # счёт без сумм
+ [L(1), "ООО Поставщик", "1", "2", None, None, 10, None, 10, None],
+ [L(1), "ООО Второй", None, "3"],
+ ["Итого", None, None, None, None, 10, None, 10, None],
+ ]
+ )
+
+ parsed = parse(sheet, rules)
+
+ account = parsed.one("60")
+ assert account.group_type == "ACCOUNT"
+ assert account.body.sums == (None,) * 6
+ assert account.extra is None
+ first = parsed.one("ООО Поставщик").extra
+ assert first is not None
+ assert len(first) == len(set(first)) == DUPLICATED_EXTRA_COLUMNS
+ assert sorted(value or "" for value in first.values()) == ["1", "2"]
+ second = parsed.one("ООО Второй").extra
+ assert second is not None
+ assert set(second) == set(first)
+ assert sorted(value or "" for value in second.values()) == ["", "3"]
+
+
+def test_footer_after_total_is_not_data_nor_extra(rules: Rules) -> None:
+ """T4.5: подвал после «Итого» (подписи, числа правее таблицы) не попадает ни в строки, ни в extra."""
+ sheet = osv(
+ r(0, "60", S100),
+ r(1, "ООО Поставщик", S100, inn="7701234567"),
+ r(None, "Итого", S100),
+ [],
+ ["Главный бухгалтер", None, "Иванова"], # подпись в колонке КПП
+ [None, None, None, None, None, None, None, None, None, None, 375490642], # правее таблицы
+ )
+
+ parsed = parse(sheet, rules)
+
+ assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик", "Итого"]
+ assert all(row.extra is None for row in parsed.rows)
+ assert parsed.total_row == parsed.one("Итого").body.row
+
+
+def test_column_filled_only_in_footer_is_not_an_extra_key(rules: Rules) -> None:
+ """T4.5: колонка, заполненная только в подвале, не становится ключом `extra` у строк таблицы."""
+ sheet = sheet_from_rows(
+ [
+ ["ООО Ромашка"],
+ [TITLE],
+ [],
+ ["Счет", "КПП", "Код", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
+ ["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ [L(0), "60", None, None, 100, None, None, None, 100, None],
+ [L(1), "ООО Поставщик", "770101001", None, 100, None, None, None, 100, None],
+ ["Итого", None, None, 100, None, None, None, 100, None],
+ ["Главный бухгалтер", None, "подпись"],
+ ]
+ )
+
+ parsed = parse(sheet, rules)
+
+ assert parsed.one("ООО Поставщик").extra == {"КПП": "770101001"}
+
+
+def test_rows_with_values_only_right_of_table_are_skipped(rules: Rules) -> None:
+ sheet = osv(
+ r(0, "60", S100),
+ [None, None, None, None, None, None, None, None, None, None, 777], # только правее таблицы
+ r(1, "ООО Поставщик", S100, inn="7701234567"),
+ r(None, "Итого", S100),
+ )
+
+ parsed = parse(sheet, rules)
+
+ assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик", "Итого"]
+
+
+# --- Пустые строки, пробелы, повтор заголовка ---
+
+
+def test_blank_and_whitespace_rows_do_not_break_tree(rules: Rules) -> None:
+ """Review Focus 5: пустая строка и строка из пробелов между блоками пропускаются, дерево не рвётся."""
+ sheet = osv(
+ r(0, "60", S100), # 7
+ [], # 8
+ [" ", " ", None, " "], # 9
+ r(1, "ООО Поставщик", S100, inn="7701234567"), # 10
+ r(None, "Итого", S100), # 11
+ )
+
+ parsed = parse(sheet, rules)
+
+ assert [row.body.row for row in parsed.rows] == [7, 10, 11]
+ assert parsed.one("ООО Поставщик").parent == ACCOUNT_ROW
+
+
+def test_header_repeat_is_skipped(rules: Rules) -> None:
+ """Review Focus 4: повтор заголовка внутри данных (печать по страницам) — не строка данных и не OTHER."""
+ sheet = osv(
+ r(0, "60", S100), # 7
+ ["Счет", "ИНН", "КПП", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 8
+ ["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], # 9
+ r(1, "ООО Поставщик", S100, inn="7701234567"), # 10
+ r(None, "Итого", S100),
+ )
+
+ parsed = parse(sheet, rules)
+
+ assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик", "Итого"]
+ assert parsed.one("ООО Поставщик").parent == ACCOUNT_ROW
+
+
+# --- TOTAL ---
+
+
+def test_empty_osv_total_without_sums(rules: Rules) -> None:
+ """R-13: пустая ОСВ — только «Итого» без сумм: одна строка TOTAL, иерархия trivial."""
+ parsed = parse(osv(r(None, "Итого")), rules)
+
+ assert [(row.group_type, row.level, row.parent) for row in parsed.rows] == [("TOTAL", 0, None)]
+ assert parsed.levels.strategy == TRIVIAL_STRATEGY
+ assert parsed.total_row == parsed.rows[0].body.row
+
+
+def test_total_without_sums_in_account_pattern_sheet_stays_root(rules: Rules) -> None:
+ """T6-pointer: «Итого» без сумм выбирается по тексту и положению до уровней — иначе account_pattern сделал бы
+ его ребёнком последнего счёта.
+ """
+ sheet = osv(
+ r(None, "60", S100),
+ r(None, "60.01", S100),
+ r(None, "ООО Поставщик", S100),
+ r(None, "Итого"),
+ )
+
+ parsed = parse(sheet, rules)
+
+ assert parsed.levels.strategy == "account_pattern"
+ total = parsed.one("Итого")
+ assert (total.group_type, total.level, total.parent) == ("TOTAL", 0, None)
+ # Негатив (T8.5): то же «Итого», не переданное в каскад, account_pattern вложил бы под последний счёт
+ header = find_table_header(sheet, rules)
+ body, _, _ = collect_body_rows(sheet, header, rules, Deadline(TIMEOUT_SECONDS))
+ unpinned = resolve_levels(body, None, Deadline(TIMEOUT_SECONDS))
+ assert unpinned.by_row[total.body.row] > 0
+
+
+def test_counterparty_named_like_total_in_the_middle(rules: Rules) -> None:
+ """T3.3: «Итого-Сервис» в середине данных — контрагент; TOTAL — последняя строка «Итого»."""
+ sheet = osv(
+ r(0, "60", S100),
+ r(1, "Итого-Сервис", S100, inn="7701234567"),
+ r(None, "Итого", S100),
+ )
+
+ parsed = parse(sheet, rules)
+
+ assert parsed.one("Итого-Сервис").group_type == "COUNTERPARTY"
+ assert parsed.one("Итого").group_type == "TOTAL"
+ assert [row.group_type for row in parsed.rows].count("TOTAL") == 1
+
+
+def test_last_row_named_like_total_is_rejected_by_outline_level(rules: Rules) -> None:
+ """Последняя строка «Итого-Сервис» без настоящего «Итого»: outline глубже минимального — не TOTAL."""
+ sheet = osv(
+ r(0, "60", S100),
+ r(1, "Итого-Сервис", S100, inn="7701234567"),
+ )
+
+ parsed = parse(sheet, rules)
+
+ assert parsed.total_row is None
+ assert parsed.one("Итого-Сервис").group_type == "COUNTERPARTY"
+
+
+def test_known_risk_last_counterparty_named_like_total_without_outline(rules: Rules) -> None:
+ """Известный риск (документирован): без outline и отступов последняя строка «Итого-Сервис» при отсутствии
+ настоящего «Итого» неотличима от итоговой строки и становится TOTAL; контрольные суммы §1.4 (TOTAL = корень)
+ это поймают, если суммы не совпадают.
+ """
+ sheet = osv(
+ r(None, "60", S100),
+ r(None, "Итого-Сервис", (1, None, None, None, 1, None)),
+ )
+
+ parsed = parse(sheet, rules)
+
+ assert parsed.one("Итого-Сервис").group_type == "TOTAL"
+
+
+# --- Свёрнутое сальдо (R-1) ---
+
+
+def test_collapsed_closing_split_by_sign(rules: Rules) -> None:
+ """Spec «ОСВ без сальдо на начало и со свёрнутым сальдо»: отрицательное свёрнутое — в closing_credit."""
+ sheet = sheet_from_rows(
+ [
+ ["ООО Ромашка"],
+ [TITLE],
+ [],
+ ["Счет", M(TURNOVER, 2), M(CLOSING, 1, 2)],
+ ["Контрагенты", "Дебет", "Кредит", None],
+ [L(0), "60", 10, 30, -20],
+ [L(1), "ООО Первый", 10, None, 10],
+ [L(1), "ООО Второй", None, 30, -30],
+ [L(1), "ООО Третий", None, None, 0],
+ [L(1), "ООО Четвёртый"],
+ ["Итого", 10, 30, -20],
+ ]
+ )
+
+ parsed = parse(sheet, rules)
+
+ sums = {row.body.text: row.body.sums for row in parsed.rows}
+ two = Decimal("0.00")
+ assert sums["ООО Первый"] == (None, None, Decimal("10.00"), None, Decimal("10.00"), None)
+ assert sums["ООО Второй"] == (None, None, None, Decimal("30.00"), None, Decimal("30.00"))
+ assert sums["ООО Третий"] == (None, None, None, None, two, None)
+ assert sums["ООО Четвёртый"] == (None,) * 6
+ assert sums["Итого"] == (None, None, Decimal("10.00"), Decimal("30.00"), None, Decimal("20.00"))
+ collapsed = [warning for warning in parsed.warnings if "свёрнутое сальдо" in warning]
+ assert len(collapsed) == 1
+ assert "closing" in collapsed[0]
+
+
+def test_no_collapsed_warning_for_regular_sheet(rules: Rules) -> None:
+ parsed = parse(_basic(), rules)
+
+ assert parsed.warnings == []
+
+
+# --- Суммы и предупреждения ---
+
+
+def test_sums_in_column_map_order_and_money_warnings_with_row(rules: Rules) -> None:
+ sheet = osv(
+ r(0, "60", ("1 234,50", "—", "(10,00)", "abc", Decimal("0.005"), None)), # 7
+ r(None, "Итого"),
+ )
+
+ parsed = parse(sheet, rules)
+
+ assert parsed.one("60").body.sums == (
+ Decimal("1234.50"),
+ None,
+ Decimal("-10.00"),
+ None,
+ Decimal("0.01"),
+ None,
+ )
+ assert len(parsed.warnings) == 1
+ assert "строка 7" in parsed.warnings[0]
+ assert "abc" in parsed.warnings[0]
+
+
+def test_body_row_style_fields(rules: Rules) -> None:
+ sheet = osv(
+ r(None, C("60", bold=True), S100),
+ r(None, C("ООО Поставщик", indent=2), S100),
+ r(None, "Итого", S100),
+ )
+
+ header = find_table_header(sheet, rules)
+ body, _, _ = collect_body_rows(sheet, header, rules, Deadline(TIMEOUT_SECONDS))
+
+ account, counterparty, _ = body
+ assert (account.is_account, account.account_rank, account.bold) == (True, 1, True)
+ assert (counterparty.is_account, counterparty.account_rank, counterparty.indent) == (False, None, 2)
+
+
+def test_account_rank_counts_segments(rules: Rules) -> None:
+ sheet = osv(
+ r(0, "76", S100),
+ r(1, "76.07", S100),
+ r(2, "76.07.1, Прочие", S100),
+ r(None, "Итого", S100),
+ )
+
+ header = find_table_header(sheet, rules)
+ body, _, _ = collect_body_rows(sheet, header, rules, Deadline(TIMEOUT_SECONDS))
+
+ assert [row.account_rank for row in body] == [1, 2, 3, None]
+
+
+# --- Дедлайн ---
+
+
+def test_collect_body_rows_checks_deadline(rules: Rules) -> None:
+ data = [r(1, f"ООО Поставщик {index}", S100) for index in range(1200)]
+ sheet = osv(r(0, "60", S100), *data, r(None, "Итого", S100))
+ header = find_table_header(sheet, rules)
+ # Часы: 0 при создании дедлайна, дальше — далеко за лимитом; первая же проверка внутри обхода строк бросает
+ readings = iter([0.0])
+ deadline = Deadline(EXPIRED_AFTER_SECONDS, clock=lambda: next(readings, EXPIRED_AFTER_SECONDS * 2.0))
+
+ with pytest.raises(ParseTimeoutError):
+ collect_body_rows(sheet, header, rules, deadline)
+
+
+def test_classified_rows_cover_every_body_row(rules: Rules) -> None:
+ parsed = parse(_basic(), rules)
+
+ assert [row.level for row in parsed.rows] == [parsed.levels.by_row[row.body.row] for row in parsed.rows]
diff --git a/api/tests/unit/parser/test_cli.py b/api/tests/unit/parser/test_cli.py
new file mode 100644
index 0000000..52d65b1
--- /dev/null
+++ b/api/tests/unit/parser/test_cli.py
@@ -0,0 +1,185 @@
+"""CLI разбора файла `python -m src.parser.cli` (Task 10; spec tabular-recognition-service, README «Запуск на файле»).
+
+Коды возврата: 0 — `done`, 1 — `needs_check`, 2 — `error`, 3 — файл не найден / не читается / ошибка использования.
+Синтетические книги собираются openpyxl в памяти (`test_osv_parser.xlsx`) и пишутся в `tmp_path`; реальный файл —
+запуск как модуля через subprocess (маркер `fixtures`).
+"""
+
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any
+
+import pytest
+from result_contract import schema_errors
+from test_osv_parser import mini_osv, xlsx
+
+from src.contract import RecognitionResult
+from src.parser import cli
+
+API_DIR = Path(__file__).resolve().parents[3]
+REAL_OSV_66 = Path("osv") / "rb" / "2024-FY" / "ОСВ_66 за 2024.xls"
+SPOILED = (60, None, 20, 10, 71, None)
+EXIT_DONE, EXIT_NEEDS_CHECK, EXIT_ERROR, EXIT_USAGE = 0, 1, 2, 3
+
+
+def _write(tmp_path: Path, name: str, data: bytes) -> Path:
+ path = tmp_path / name
+ path.write_bytes(data)
+ return path
+
+
+def _run(capsys: pytest.CaptureFixture[str], *argv: str) -> tuple[int, str, str]:
+ code = cli.main(list(argv))
+ captured = capsys.readouterr()
+ return code, captured.out, captured.err
+
+
+def test_done_book_returns_0_and_prints_result_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
+
+ code, out, _err = _run(capsys, str(path))
+
+ assert code == EXIT_DONE
+ payload: dict[str, Any] = json.loads(out)
+ assert payload["status"] == "done"
+ assert payload["schema_version"] == "1.1"
+ assert schema_errors(payload) == []
+ assert RecognitionResult.model_validate(payload).to_wire() == payload
+ assert payload["documents"][0]["header"]["account_code_raw"] == "60"
+ assert "ОСВ 60" in out # не ASCII-экранирование
+ assert out.endswith("\n")
+
+
+def test_output_is_indented_and_keeps_model_field_order(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
+
+ _code, out, _err = _run(capsys, str(path))
+
+ assert out.startswith('{\n "schema_version": "1.1",\n')
+ keys = list(json.loads(out))
+ assert keys != sorted(keys) # sort_keys не используется: порядок полей модели значим для golden
+
+
+def test_money_is_printed_as_strings(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
+
+ _code, out, _err = _run(capsys, str(path))
+
+ row = json.loads(out)["documents"][0]["rows"][1]
+ assert row["opening_debit"] == "60.00"
+
+
+def test_needs_check_book_returns_1(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = _write(tmp_path, "spoiled.xlsx", xlsx({"ОСВ 60": mini_osv(SPOILED)}))
+
+ code, out, _err = _run(capsys, str(path))
+
+ assert code == EXIT_NEEDS_CHECK
+ payload = json.loads(out)
+ assert payload["status"] == "needs_check"
+ assert schema_errors(payload) == []
+
+
+def test_error_outcome_returns_2_with_error_json(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = _write(tmp_path, "broken.xlsx", b"PK\x03\x04 broken zip")
+
+ code, out, err = _run(capsys, str(path))
+
+ assert code == EXIT_ERROR
+ payload = json.loads(out)
+ assert payload["status"] == "error"
+ assert payload["error"]["code"] == "UNREADABLE"
+ assert payload["error"]["message"]
+ assert "UNREADABLE" in err
+
+
+def test_missing_file_returns_3(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ code, out, err = _run(capsys, str(tmp_path / "nope.xlsx"))
+
+ assert code == EXIT_USAGE
+ assert out == ""
+ assert "nope.xlsx" in err
+
+
+def test_directory_instead_of_file_returns_3(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ code, out, _err = _run(capsys, str(tmp_path))
+
+ assert code == EXIT_USAGE
+ assert out == ""
+
+
+def test_unknown_extension_without_file_type_returns_3(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = _write(tmp_path, "osv.dat", xlsx({"ОСВ 60": mini_osv()}))
+
+ code, out, err = _run(capsys, str(path))
+
+ assert code == EXIT_USAGE
+ assert out == ""
+ assert "--file-type" in err
+
+
+def test_explicit_file_type_overrides_extension(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = _write(tmp_path, "osv.dat", xlsx({"ОСВ 60": mini_osv()}))
+
+ code, out, _err = _run(capsys, str(path), "--file-type", "XLSX")
+
+ assert code == EXIT_DONE
+ assert json.loads(out)["status"] == "done"
+
+
+def test_extension_is_case_insensitive(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = _write(tmp_path, "OSV.XLSX", xlsx({"ОСВ 60": mini_osv()}))
+
+ code, _out, _err = _run(capsys, str(path))
+
+ assert code == EXIT_DONE
+
+
+def test_invalid_file_type_value_returns_3_not_argparse_2(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
+
+ code, out, err = _run(capsys, str(path), "--file-type", "PDF")
+
+ assert code == EXIT_USAGE
+ assert out == ""
+ assert "PDF" in err
+
+
+def test_no_arguments_returns_3(capsys: pytest.CaptureFixture[str]) -> None:
+ code, out, _err = _run(capsys)
+
+ assert code == EXIT_USAGE
+ assert out == ""
+
+
+def test_output_is_deterministic_except_duration(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
+ path = _write(tmp_path, "osv.xlsx", xlsx({"ОСВ 60": mini_osv()}))
+
+ first = json.loads(_run(capsys, str(path))[1])
+ second = json.loads(_run(capsys, str(path))[1])
+
+ for payload in (first, second):
+ payload["metrics"].pop("duration_ms")
+ assert first == second
+
+
+@pytest.mark.fixtures
+def test_module_run_on_real_file_prints_valid_json(fixtures_dir: Path) -> None:
+ """`python -m src.parser.cli <файл>` из `api/`: stdout — JSON результата по схеме, код возврата 0."""
+ completed = subprocess.run(
+ [sys.executable, "-m", "src.parser.cli", str(fixtures_dir / REAL_OSV_66)],
+ cwd=API_DIR,
+ env={**os.environ, "ENV_FILE": ".env.test"}, # импорт пакета `src` читает настройки сервиса
+ capture_output=True,
+ check=False,
+ timeout=120,
+ )
+
+ assert completed.returncode == EXIT_DONE, completed.stderr.decode("utf-8", "replace")
+ payload = json.loads(completed.stdout.decode("utf-8"))
+ assert payload["status"] == "done"
+ assert schema_errors(payload) == []
+ assert payload["documents"][0]["header"]["account_code_raw"] == "66"
diff --git a/api/tests/unit/parser/test_deadline.py b/api/tests/unit/parser/test_deadline.py
new file mode 100644
index 0000000..bd57506
--- /dev/null
+++ b/api/tests/unit/parser/test_deadline.py
@@ -0,0 +1,61 @@
+"""Кооперативный дедлайн разбора (design D-8, ruling R-3): проверка между шагами, часы подменяются в тестах."""
+
+import inspect
+import time
+
+import pytest
+
+from src.parser.deadline import Deadline
+from src.parser.errors import ParseTimeoutError
+
+
+class FakeClock:
+ """Часы-заглушка: время двигается только вручную."""
+
+ def __init__(self) -> None:
+ self.now = 1000.0
+
+ def __call__(self) -> float:
+ return self.now
+
+
+def test_check_passes_within_limit() -> None:
+ clock = FakeClock()
+ deadline = Deadline(10, clock=clock)
+
+ clock.now += 10
+
+ deadline.check("иерархия") # ровно на границе лимита — ещё не превышен
+
+
+def test_check_raises_after_limit_with_step_name() -> None:
+ clock = FakeClock()
+ deadline = Deadline(120, clock=clock)
+
+ clock.now += 120.5
+
+ with pytest.raises(ParseTimeoutError) as raised:
+ deadline.check("иерархия (outline)")
+
+ assert raised.value.message == "превышен лимит 120 с на шаге иерархия (outline)"
+
+
+def test_limit_is_counted_from_creation() -> None:
+ clock = FakeClock()
+ clock.now += 500
+ deadline = Deadline(1, clock=clock)
+
+ deadline.check("старт")
+
+
+def test_default_clock_is_monotonic() -> None:
+ default = inspect.signature(Deadline).parameters["clock"].default
+
+ assert default is time.monotonic
+
+
+def test_default_clock_measures_real_time() -> None:
+ Deadline(60).check("сразу") # монотонные часы: свежий лимит не превышен
+
+ with pytest.raises(ParseTimeoutError):
+ Deadline(-1).check("уже в прошлом") # граница раньше «сейчас» по тем же часам
diff --git a/api/tests/unit/parser/test_detect.py b/api/tests/unit/parser/test_detect.py
new file mode 100644
index 0000000..9cfe023
--- /dev/null
+++ b/api/tests/unit/parser/test_detect.py
@@ -0,0 +1,826 @@
+"""Детекторы по содержимому (design D-2, spec «Листы ОСВ находятся по содержимому», «Заголовок таблицы и колонки
+сумм определяются по тексту»): вид листа, блок заголовка, шесть колонок сумм, колонки текста и ИНН, extra, повтор
+заголовка в данных. Сетки собираются `gridbuilder`; позиции в них произвольные — детектор их не знает заранее.
+"""
+
+from decimal import Decimal
+
+import pytest
+from gridbuilder import L, M, at, sheet_from_rows
+
+from src.parser.deadline import Deadline
+from src.parser.detect import (
+ DEADLINE_CHECK_EVERY_ROWS,
+ ColumnMap,
+ SheetKind,
+ TableHeader,
+ classify_sheet,
+ find_table_header,
+ first_data_row,
+ inn_from_cell,
+ is_header_repeat,
+)
+from src.parser.errors import InvalidFormatError, ParseTimeoutError
+from src.parser.grid import Cell, Sheet
+from src.parser.numbers import inn_digits
+from src.parser.rules import Rules, load_rules
+
+TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
+OPENING = "Сальдо на начало периода"
+TURNOVER = "Обороты за период"
+CLOSING = "Сальдо на конец периода"
+
+
+@pytest.fixture(scope="module")
+def rules() -> Rules:
+ return load_rules()
+
+
+def _doc_head() -> list[list[object]]:
+ """Шапка варианта 1 (строки 1–5); заголовок таблицы — с строки 6."""
+ return [["АО «Ромашка»"], [TITLE], [], ["Выводимые данные: БУ (данные бухгалтерского учета)"], []]
+
+
+def _rb_like() -> Sheet:
+ """Вариант 1, заголовок в две строки (как АО РБ): суммы E/H, J/L, O/Q, подзаголовки merged на две строки."""
+ return sheet_from_rows(
+ [
+ *_doc_head(),
+ [M("Счет", 4), M(OPENING, 5), M(TURNOVER, 5), M(CLOSING, 3)], # 6
+ [M("Контрагенты", 4, 2), M("Дебет", 3, 2), M("Кредит", 2, 2), M("Дебет", 2, 2)]
+ + [M("Кредит", 3, 2), M("Дебет", 2, 2), M("Кредит", 1, 2)], # 7
+ [], # 8 — под merged-подзаголовками
+ [L(0), *at({1: "60", 8: 300, 10: 1000, 12: 900, 17: 200})], # 9
+ [L(1), *at({1: "60.01", 8: 300, 10: 1000, 12: 900, 17: 200})],
+ [L(2), *at({1: "ООО Поставщик", 8: 300, 10: 1000, 12: 900, 17: 200})],
+ at({1: "Итого", 8: 300, 10: 1000, 12: 900, 17: 200}),
+ ]
+ )
+
+
+def _schastl_like() -> Sheet:
+ """Вариант 1, три строки (как Счастливочная): Счет / Подразделение / Контрагенты + ИНН в E, суммы G/J, K/N, Q/S."""
+ return sheet_from_rows(
+ [
+ *_doc_head(),
+ [M("Счет", 6), M(OPENING, 4), M(TURNOVER, 6), M(CLOSING, 3)], # 6
+ [M("Подразделение", 6), M("Дебет", 3, 2), M("Кредит", 1, 2), M("Дебет", 3, 2)]
+ + [M("Кредит", 3, 2), M("Дебет", 2, 2), M("Кредит", 1, 2)], # 7
+ [M("Контрагенты", 4), M("ИНН", 2)], # 8
+ [L(0), *at({1: "62", 7: 500, 11: 100, 14: 50, 17: 550})], # 9
+ [L(1), *at({1: "62.01", 7: 500, 11: 100, 14: 50, 17: 550})],
+ [L(2), *at({1: "БП Аренда", 7: 500, 11: 100, 14: 50, 17: 550})],
+ [L(3), *at({1: "МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО", 5: "3523023093", 7: 500, 11: 100, 14: 50, 17: 550})],
+ at({1: "Итого", 7: 500, 11: 100, 14: 50, 17: 550, 20: 777, 21: Decimal("0.5")}), # мусор правее S
+ ]
+ )
+
+
+# --- classify_sheet ---
+
+
+def test_classify_osv_sheet_by_title_anywhere_in_first_rows(rules: Rules) -> None:
+ sheet = sheet_from_rows([[], [None, None, "ОБОРОТНО-САЛЬДОВАЯ\nведомость по счёту 60"]])
+
+ assert classify_sheet(sheet, rules) is SheetKind.OSV
+
+
+@pytest.mark.parametrize("marker", ["Бухгалтерский баланс", "Отчет о финансовых результатах", "Форма по ОКУД 0710001"])
+def test_classify_statement_sheet(rules: Rules, marker: str) -> None:
+ sheet = sheet_from_rows([["ООО Ромашка"], [marker]])
+
+ assert classify_sheet(sheet, rules) is SheetKind.STATEMENT
+
+
+def test_classify_other_sheet(rules: Rules) -> None:
+ sheet = sheet_from_rows([["Справка"], ["Наименование", "Сумма"], ["Аренда", 100]])
+
+ assert classify_sheet(sheet, rules) is SheetKind.OTHER
+
+
+def test_osv_marker_wins_over_statement_marker(rules: Rules) -> None:
+ sheet = sheet_from_rows([["Бухгалтерский баланс"], [TITLE]])
+
+ assert classify_sheet(sheet, rules) is SheetKind.OSV
+
+
+def test_osv_marker_far_below_the_head_is_not_searched(rules: Rules) -> None:
+ sheet = sheet_from_rows([*([["строка"]] * 40), [TITLE]])
+
+ assert classify_sheet(sheet, rules) is SheetKind.OTHER
+
+
+# --- find_table_header: варианты заголовка ---
+
+
+def test_two_row_header_rb_like(rules: Rules) -> None:
+ sheet = _rb_like()
+
+ header = find_table_header(sheet, rules)
+
+ assert header.columns == ColumnMap(
+ opening_debit=5,
+ opening_credit=8,
+ turnover_debit=10,
+ turnover_credit=12,
+ closing_debit=15,
+ closing_credit=17,
+ collapsed=frozenset(),
+ )
+ assert header.columns.as_diagnostics() == {
+ "opening_debit": "E",
+ "opening_credit": "H",
+ "turnover_debit": "J",
+ "turnover_credit": "L",
+ "closing_debit": "O",
+ "closing_credit": "Q",
+ }
+ assert (header.first_row, header.last_row) == (6, 8)
+ assert header.text_col == 1
+ assert header.inn_col is None
+ assert [header.right_edge] == [17]
+ assert header.extra_cols == {}
+ assert header.group_label_rows == [6, 7]
+ assert [first_data_row(sheet, header)] == [9]
+
+
+def test_three_row_header_with_inn_schastl_like(rules: Rules) -> None:
+ sheet = _schastl_like()
+
+ header = find_table_header(sheet, rules)
+
+ assert header.columns.as_diagnostics() == {
+ "opening_debit": "G",
+ "opening_credit": "J",
+ "turnover_debit": "K",
+ "turnover_credit": "N",
+ "closing_debit": "Q",
+ "closing_credit": "S",
+ }
+ assert [header.inn_col] == [5]
+ assert (header.first_row, header.last_row) == (6, 8)
+ assert header.group_label_rows == [6, 7, 8]
+ assert [first_data_row(sheet, header)] == [9]
+
+
+def test_garbage_right_of_table_is_neither_sum_nor_extra(rules: Rules) -> None:
+ header = find_table_header(_schastl_like(), rules)
+
+ assert [header.right_edge] == [19]
+ assert header.extra_cols == {}
+ sums = {
+ header.columns.opening_debit,
+ header.columns.opening_credit,
+ header.columns.turnover_debit,
+ header.columns.turnover_credit,
+ header.columns.closing_debit,
+ header.columns.closing_credit,
+ }
+ assert not sums & {20, 21}
+
+
+def test_four_row_header(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ *_doc_head(),
+ ["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 6
+ ["Подразделение", None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ ["Контрагенты", "ИНН"],
+ ["Договоры"], # 9
+ [L(0), *at({1: "62", 3: 10, 8: 10})],
+ [L(3), *at({1: "Договор 1", 3: 10, 8: 10})],
+ ]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert (header.first_row, header.last_row) == (6, 9)
+ assert header.group_label_rows == [6, 7, 8, 9]
+ assert [header.inn_col] == [2]
+ assert [header.columns.opening_debit] == [3]
+ assert [header.columns.closing_credit] == [8]
+ assert [first_data_row(sheet, header)] == [10]
+
+
+def test_label_row_of_other_grouping_belongs_to_the_block(rules: Rules) -> None:
+ # «Основные средства» — группировка OTHER из словаря osv_groups: строка блока, а не первая строка данных
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
+ ["Подразделение", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ ["Основные средства"],
+ [L(0), *at({1: "01", 2: 10, 6: 10})],
+ ]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert header.group_label_rows == [2, 3, 4]
+ assert [first_data_row(sheet, header)] == [5]
+
+
+def test_counterparty_named_like_a_grouping_right_after_header_is_data(rules: Rules) -> None:
+ # «Контрагенты-Сервис ООО» матчит метку «Контрагент…», но у неё суммы — это строка данных, а не часть блока
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
+ ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ [L(1), *at({1: "Контрагенты-Сервис ООО", 2: 10, 6: 10})],
+ ]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert [header.last_row] == [3]
+ assert [first_data_row(sheet, header)] == [4]
+
+
+def test_variant_2_subkonto_header(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ ["Оборотно-сальдовая ведомость по счету 60"],
+ ["Период: 1 квартал 2026 г."],
+ ["Детализация по субсчетам, субконто: Контрагенты, Договоры"],
+ ["Выводимые данные: сумма"],
+ ["Субконто", M("Сальдо на начало периода", 2), M("Оборот за период", 2), M(CLOSING, 2)], # 5
+ [None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ [L(0), *at({1: "60", 2: 10, 7: 10})],
+ ]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert (header.first_row, header.last_row) == (5, 6)
+ assert header.group_label_rows == [5]
+ assert [header.columns.turnover_debit] == [4]
+ assert header.text_col == 1
+
+
+def test_one_row_header_with_sides_in_group_cells(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ [
+ "Счет",
+ "Нач. сальдо Дт",
+ "Нач. сальдо Кт",
+ "Оборот Дт",
+ "Оборот Кт",
+ "Кон. сальдо Д-т",
+ "Кон. сальдо К-т",
+ ],
+ [L(0), *at({1: "60", 2: 10, 7: 10})],
+ ]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert header.columns == ColumnMap(2, 3, 4, 5, 6, 7, frozenset())
+ assert (header.first_row, header.last_row) == (2, 2)
+ assert [first_data_row(sheet, header)] == [3]
+
+
+def test_unmerged_group_titles_take_subheaders_up_to_the_next_group(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ ["Счет", OPENING, None, TURNOVER, None, CLOSING],
+ [None, "Дт", "Кт", "Дт", "Кт", "Дт", "Кт"],
+ [L(0), *at({1: "60", 2: 10, 7: 10})],
+ ]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert header.columns == ColumnMap(2, 3, 4, 5, 6, 7, frozenset())
+ assert [header.right_edge] == [7]
+
+
+def test_table_text_column_is_not_necessarily_the_first(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ [None, "Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
+ [None, "Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ [L(0), *at({2: "60", 3: 10, 8: 10})],
+ [L(1), *at({2: "ООО Поставщик", 3: 10, 8: 10})],
+ ]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert [header.text_col] == [2]
+ assert [header.columns.opening_debit] == [3]
+ assert header.group_label_rows == [2, 3]
+
+
+# --- Без «начала», свёрнутое сальдо ---
+
+
+def test_header_without_opening_group(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ ["Счет", M(TURNOVER, 2), M(CLOSING, 2)],
+ ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит"],
+ [L(0), *at({1: "60", 2: 10, 5: 10})],
+ ]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert header.columns == ColumnMap(None, None, 2, 3, 4, 5, frozenset())
+ assert header.columns.as_diagnostics() == {
+ "turnover_debit": "B",
+ "turnover_credit": "C",
+ "closing_debit": "D",
+ "closing_credit": "E",
+ }
+
+
+def test_collapsed_closing_balance_is_one_column_in_debit_slot(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 1, 2)],
+ ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", None],
+ [L(0), *at({1: "60", 2: 10, 4: 5, 6: 15})],
+ [L(1), *at({1: "ООО Должник", 2: 10, 4: 5, 6: -15})],
+ ]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert [header.columns.closing_debit] == [6]
+ assert header.columns.closing_credit is None
+ assert header.columns.collapsed == frozenset({"closing"})
+ assert [header.right_edge] == [6]
+ assert header.columns.as_diagnostics()["closing_debit"] == "F"
+
+
+def test_collapsed_group_with_empty_subheaders_under_wide_title(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
+ ["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит"],
+ [L(0), *at({1: "60", 2: 10, 4: 5, 6: 15})],
+ ]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert header.columns.collapsed == frozenset({"opening"})
+ assert (header.columns.opening_debit, header.columns.opening_credit) == (2, None)
+
+
+def test_collapsed_turnover_is_invalid_format(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ ["Счет", M(OPENING, 2), M(TURNOVER, 1, 2), M(CLOSING, 2)],
+ ["Контрагенты", "Дебет", "Кредит", None, "Дебет", "Кредит"],
+ [L(0), *at({1: "60", 2: 10, 6: 10})],
+ ]
+ )
+
+ with pytest.raises(InvalidFormatError, match="обороты"):
+ find_table_header(sheet, rules)
+
+
+def test_group_with_debit_but_no_credit_is_invalid_format(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
+ ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Итог"],
+ [L(0), *at({1: "60", 2: 10, 6: 10})],
+ ]
+ )
+
+ with pytest.raises(InvalidFormatError, match=r"сальдо на конец.*Кредит"):
+ find_table_header(sheet, rules)
+
+
+# --- Ошибки: нет заголовка ---
+
+
+def test_no_table_header_is_invalid_format_naming_the_groups(rules: Rules) -> None:
+ sheet = sheet_from_rows([[TITLE], ["Наименование", "Сумма"], ["Аренда", 100]])
+
+ with pytest.raises(InvalidFormatError, match="сальдо на начало") as caught:
+ find_table_header(sheet, rules)
+
+ assert "обороты" in caught.value.message
+ assert "сальдо на конец" in caught.value.message
+
+
+def test_single_group_is_not_a_header_and_missing_groups_are_named(rules: Rules) -> None:
+ sheet = sheet_from_rows([[TITLE], ["Счет", M(OPENING, 2)], [None, "Дебет", "Кредит"], ["60", 10]])
+
+ with pytest.raises(InvalidFormatError) as caught:
+ find_table_header(sheet, rules)
+
+ assert "обороты" in caught.value.message
+ assert "сальдо на конец" in caught.value.message
+ assert "сальдо на начало" not in caught.value.message
+
+
+def test_header_without_text_column_left_of_sums_is_invalid_format(rules: Rules) -> None:
+ sheet = sheet_from_rows([[TITLE], [M(TURNOVER, 2), M(CLOSING, 2)], ["Дебет", "Кредит", "Дебет", "Кредит"], [1, 2]])
+
+ with pytest.raises(InvalidFormatError, match="текст"):
+ find_table_header(sheet, rules)
+
+
+# --- ИНН и extra ---
+
+
+def test_kpp_between_inn_and_sums_goes_to_extra(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ ["Счет", None, None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 2
+ ["Контрагенты", "ИНН", "КПП", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ [L(0), *at({1: "60", 4: 10, 9: 10})],
+ [L(1), *at({1: "ООО Поставщик", 2: "7701234567", 3: "770101001", 4: 10, 9: 10, 11: 99})],
+ ]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert [header.inn_col] == [2]
+ assert header.extra_cols == {3: "КПП"}
+ assert [header.columns.opening_debit] == [4]
+ assert [header.right_edge] == [9]
+
+
+def test_unnamed_column_with_data_is_extra_under_its_letter(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ ["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
+ ["Контрагенты", None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ [L(0), *at({1: "60", 2: "прим.", 3: 10, 8: 10})],
+ ]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert header.extra_cols == {2: "B"}
+
+
+def test_inn_column_found_by_digits_without_header(rules: Rules) -> None:
+ rows: list[list[object]] = [
+ [TITLE],
+ ["Счет", None, None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
+ ["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ [L(0), *at({1: "60", 4: 10, 9: 10})],
+ ]
+ inns: list[object] = ["7701234567", Decimal("3523023093"), "770123456789", "7701234567", "не указан"]
+ for index, inn in enumerate(inns):
+ rows.append([L(1), *at({1: f"Контрагент {index}", 2: inn, 3: f"код {index}", 4: 1, 9: 1})])
+
+ header = find_table_header(sheet_from_rows(rows), rules)
+
+ assert [header.inn_col] == [2] # 4 из 5 непустых — 10/12 цифр (80 %)
+ assert header.extra_cols == {3: "C"}
+
+
+def test_mostly_non_inn_digits_are_not_an_inn_column(rules: Rules) -> None:
+ rows: list[list[object]] = [
+ [TITLE],
+ ["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
+ ["Контрагенты", "Код", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ ]
+ for index, code in enumerate(["7701234567", "12", "345", "7701234567", "000123"]):
+ rows.append([L(1), *at({1: f"Контрагент {index}", 2: code, 3: 1, 8: 1})])
+
+ header = find_table_header(sheet_from_rows(rows), rules)
+
+ assert header.inn_col is None
+ assert header.extra_cols == {2: "Код"}
+
+
+# --- Пустые строки и повтор заголовка (Review Focus 4, 5) ---
+
+
+def test_whitespace_rows_after_header_are_empty(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
+ ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ [" ", "\u00a0", None, " "],
+ [],
+ [L(0), *at({1: "60", 2: 10, 7: 10})],
+ ]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert [header.last_row] == [3]
+ assert [first_data_row(sheet, header)] == [6]
+
+
+def test_first_data_row_of_table_without_data_is_past_the_sheet(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [[TITLE], ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], ["Контрагенты", *(["Дебет", "Кредит"] * 3)]]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert first_data_row(sheet, header) == sheet.n_rows + 1
+
+
+def _with_repeated_header() -> Sheet:
+ group_row = ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)]
+ side_row = ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"]
+ return sheet_from_rows(
+ [
+ [TITLE],
+ group_row, # 2
+ side_row, # 3
+ [L(0), *at({1: "60", 2: 30, 7: 30})], # 4
+ [L(1), *at({1: "ООО Первый", 2: 10, 7: 10})], # 5
+ [], # 6
+ group_row, # 7 — повтор на «странице» печати
+ side_row, # 8
+ [L(1), *at({1: "ООО Второй", 2: 20, 7: 20})], # 9
+ ["Сальдо на начало периода", "Обороты за период", None, None, 5], # 10 — повтор без merged
+ [L(1), *at({1: "Контрагенты", 2: 1, 7: 1})], # 11 — контрагент с таким именем, но с суммами
+ ]
+ )
+
+
+def test_header_repeat_inside_data_is_recognised(rules: Rules) -> None:
+ sheet = _with_repeated_header()
+ header = find_table_header(sheet, rules)
+
+ assert is_header_repeat(sheet, 7, header, rules)
+ assert is_header_repeat(sheet, 8, header, rules)
+ assert is_header_repeat(sheet, 10, header, rules)
+
+
+@pytest.mark.parametrize("row", [4, 5, 6, 9, 11])
+def test_data_and_empty_rows_are_not_header_repeats(rules: Rules, row: int) -> None:
+ sheet = _with_repeated_header()
+ header = find_table_header(sheet, rules)
+
+ assert not is_header_repeat(sheet, row, header, rules)
+
+
+def test_header_is_found_from_the_top(rules: Rules) -> None:
+ sheet = _with_repeated_header()
+
+ header: TableHeader = find_table_header(sheet, rules)
+
+ assert [header.first_row] == [2]
+
+
+def test_row_with_only_cells_right_of_table_is_not_a_header_repeat(rules: Rules) -> None:
+ # В блоке rb-подобного заголовка есть пустая строка (под merged-подзаголовками); строка, где есть только «хвост»
+ # правее таблицы, после отсечения по right_edge тоже пуста — но это не повтор заголовка
+ base = _rb_like()
+ sheet = Sheet(
+ name=base.name,
+ cells={**base.cells, (base.n_rows + 1, 19): Cell(Decimal("375490642.46"), "number")},
+ merged=base.merged,
+ row_outline=base.row_outline,
+ n_rows=base.n_rows + 1,
+ n_cols=19,
+ )
+ header = find_table_header(sheet, rules)
+
+ assert not is_header_repeat(sheet, sheet.n_rows, header, rules)
+
+
+# --- Fix round 1 (T4.1–T4.4, T4.6) ---
+
+
+def test_header_without_turnover_is_invalid_format(rules: Rules) -> None:
+ # Допустимо отсутствие только «начала» (spec; ruling R-12): «начало» + «конец» без оборотов — не заголовок
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ ["Счет", M(OPENING, 2), M(CLOSING, 2)],
+ ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит"],
+ [L(0), *at({1: "60", 2: 10, 5: 10})],
+ ]
+ )
+
+ with pytest.raises(InvalidFormatError) as caught:
+ find_table_header(sheet, rules)
+
+ assert "«обороты»" in caught.value.message
+ assert "сальдо на начало" not in caught.value.message
+ assert "сальдо на конец" not in caught.value.message
+
+
+def test_header_without_closing_is_invalid_format(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ ["Счет", M(OPENING, 2), M(TURNOVER, 2)],
+ ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит"],
+ [L(0), *at({1: "60", 2: 10, 5: 10})],
+ ]
+ )
+
+ with pytest.raises(InvalidFormatError) as caught:
+ find_table_header(sheet, rules)
+
+ assert "«сальдо на конец»" in caught.value.message
+ assert "обороты" not in caught.value.message
+
+
+def test_preamble_row_with_opening_and_closing_is_skipped(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ ["Сальдо на начало: 01.01.2025", None, "Сальдо на конец: 31.03.2025"], # 2 — не заголовок таблицы
+ [],
+ ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 4
+ ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ [L(0), *at({1: "60", 2: 10, 7: 10})],
+ ]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert [header.first_row] == [4]
+ assert header.columns == ColumnMap(2, 3, 4, 5, 6, 7, frozenset())
+
+
+def test_one_row_header_side_after_punctuation(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ ["Счет", "Сальдо на начало, Дт", "Сальдо на начало, Кт", "Обороты: Дт", "Обороты: Кт"]
+ + ["Сальдо на конец (Дт)", "Сальдо на конец (Кт)"],
+ [L(0), *at({1: "60", 2: 10, 7: 10})],
+ ]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert header.columns == ColumnMap(2, 3, 4, 5, 6, 7, frozenset())
+
+
+@pytest.mark.parametrize(
+ ("value", "expected"),
+ [
+ # R-24: правило детектора не дополняет 9/11 цифр нулём — иначе числовая колонка КПП (9 цифр) сошла бы за
+ # колонку ИНН; ведущий ноль восстанавливает только извлечение `numbers.inn_digits` из выбранной колонки
+ (Decimal("123456789"), None),
+ (Decimal("12345678901"), None),
+ (Decimal("3523023093"), "3523023093"),
+ (Decimal("770123456789"), "770123456789"),
+ (Decimal("3523023093.0"), "3523023093"),
+ (Decimal("12345"), None),
+ (Decimal("3523023093.5"), None),
+ ("3523023093", "3523023093"),
+ ("0123456789", "0123456789"),
+ ("123456789", None), # в тексте ведущий ноль не теряется: 9 цифр — не ИНН
+ ("не указан", None),
+ ],
+)
+def test_inn_from_cell_is_strict_and_does_not_pad(value: object, expected: str | None) -> None:
+ cell = Cell(value, "number") if isinstance(value, Decimal) else Cell(value, "text") # type: ignore[arg-type]
+
+ assert inn_from_cell(cell) == expected
+
+
+def _numeric_inn_rows(inn_title: str | None) -> list[list[object]]:
+ """ИНН записаны числами, у трёх из четырёх потерян ведущий ноль (9 цифр); `inn_title` — заголовок колонки."""
+ rows: list[list[object]] = [
+ [TITLE],
+ ["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
+ ["Контрагенты", inn_title, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ ]
+ for index, inn in enumerate([123456789, 234567890, 345678901, 7701234567]):
+ rows.append([L(1), *at({1: f"Контрагент {index}", 2: inn, 3: 1, 8: 1})])
+ return rows
+
+
+def test_inn_column_of_numbers_with_lost_leading_zeros_found_by_header(rules: Rules) -> None:
+ """T4.3 + R-24: колонка с заголовком «ИНН» выбирается по заголовку; значения дополняются нулём при извлечении."""
+ sheet = sheet_from_rows(_numeric_inn_rows("ИНН"))
+
+ header = find_table_header(sheet, rules)
+
+ assert [header.inn_col] == [2]
+ assert header.extra_cols == {}
+ data = range(header.last_row + 1, sheet.n_rows + 1)
+ assert [inn_digits(sheet.cell(row, 2)) for row in data] == ["0123456789", "0234567890", "0345678901", "7701234567"]
+
+
+def test_numeric_column_with_lost_leading_zeros_without_header_is_not_inn(rules: Rules) -> None:
+ """R-24: без заголовка «ИНН» колонка из 9/10-значных чисел — не колонка ИНН (по значениям нужно ≥ 80 % ровно
+ 10/12 цифр), она уходит в extra."""
+ header = find_table_header(sheet_from_rows(_numeric_inn_rows(None)), rules)
+
+ assert header.inn_col is None
+ assert header.extra_cols == {2: "B"}
+
+
+def test_numeric_kpp_column_is_not_taken_for_inn_column(rules: Rules) -> None:
+ """M-3: числовой КПП (9 цифр) левее текстовой колонки ИНН, обе без заголовка: ИНН — колонка с 10/12 цифрами,
+ КПП не выбирается (дополнение нулём превратило бы КПП в «0» + КПП)."""
+ rows: list[list[object]] = [
+ [TITLE],
+ ["Счет", None, None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
+ ["Контрагенты", None, None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ ]
+ pairs = [(770101001, "7701234567"), (352801001, "3528052718"), (500101001, "5001012345"), (771401001, "7714012345")]
+ for index, (kpp, inn) in enumerate(pairs):
+ rows.append([L(1), *at({1: f"Контрагент {index}", 2: kpp, 3: inn, 4: 1, 9: 1})])
+
+ header = find_table_header(sheet_from_rows(rows), rules)
+
+ assert [header.inn_col] == [3]
+ assert header.extra_cols == {2: "B"}
+
+
+def test_numeric_kpp_column_alone_is_not_an_inn_column(rules: Rules) -> None:
+ rows: list[list[object]] = [
+ [TITLE],
+ ["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
+ ["Контрагенты", None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ ]
+ for index, kpp in enumerate([770101001, 352801001, 500101001, 771401001]):
+ rows.append([L(1), *at({1: f"Контрагент {index}", 2: kpp, 3: 1, 8: 1})])
+
+ header = find_table_header(sheet_from_rows(rows), rules)
+
+ assert header.inn_col is None
+ assert header.extra_cols == {2: "B"}
+
+
+def test_too_few_inn_like_values_do_not_make_an_inn_column(rules: Rules) -> None:
+ rows: list[list[object]] = [
+ [TITLE],
+ ["Счет", None, M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)],
+ ["Контрагенты", None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"],
+ ]
+ for index in range(29):
+ values: dict[int, object] = {1: f"Контрагент {index}", 3: 1, 8: 1}
+ if index == 0:
+ values[2] = "7701234567"
+ rows.append([L(1), *at(values)])
+
+ header = find_table_header(sheet_from_rows(rows), rules)
+
+ assert header.inn_col is None
+ assert header.extra_cols == {2: "B"}
+
+
+def test_vertically_merged_collapsed_title_does_not_reach_junk_to_the_right(rules: Rules) -> None:
+ sheet = sheet_from_rows(
+ [
+ [TITLE],
+ ["Счет", M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 1, 2)],
+ ["Контрагенты", "Дебет", "Кредит", "Дебет", "Кредит", None],
+ at({7: "Дт", 8: "Кт"}), # 4 — мусор правее таблицы, ниже merged-названия «конца»
+ [L(0), *at({1: "60", 2: 10, 4: 5, 6: 15})],
+ ]
+ )
+
+ header = find_table_header(sheet, rules)
+
+ assert header.columns.collapsed == frozenset({"closing"})
+ assert (header.columns.closing_debit, header.columns.closing_credit) == (6, None)
+ assert [header.right_edge] == [6]
+
+
+# --- Кооперативный дедлайн в долгих циклах (R-23, I-1) ---
+
+
+class _ClockExpiringAfter:
+ """Часы-заглушка: первые `calls` обращений — время 0, дальше — далеко за лимитом."""
+
+ def __init__(self, calls: int) -> None:
+ self._left = calls
+
+ def __call__(self) -> float:
+ self._left -= 1
+ return 0.0 if self._left >= 0 else 1e9
+
+
+def test_group_row_search_checks_deadline(rules: Rules) -> None:
+ sheet = sheet_from_rows([[f"строка {index}"] for index in range(2 * DEADLINE_CHECK_EVERY_ROWS)])
+
+ with pytest.raises(ParseTimeoutError, match="поиск заголовка таблицы"):
+ find_table_header(sheet, rules, Deadline(1, clock=_ClockExpiringAfter(1)))
+ with pytest.raises(InvalidFormatError): # без дедлайна — прежнее поведение
+ find_table_header(sheet, rules)
+
+
+def test_extra_columns_scan_checks_deadline(rules: Rules) -> None:
+ # Создание дедлайна и одна проверка при поиске строки групп — в лимите; сбор extra — уже нет
+ with pytest.raises(ParseTimeoutError, match="дополнительные колонки"):
+ find_table_header(_rb_like(), rules, Deadline(1, clock=_ClockExpiringAfter(2)))
+ header = find_table_header(_rb_like(), rules, Deadline(1, clock=_ClockExpiringAfter(10**6)))
+ assert header == find_table_header(_rb_like(), rules) # дедлайн в лимите не меняет результат
diff --git a/api/tests/unit/parser/test_grid.py b/api/tests/unit/parser/test_grid.py
new file mode 100644
index 0000000..baccddc
--- /dev/null
+++ b/api/tests/unit/parser/test_grid.py
@@ -0,0 +1,93 @@
+"""Модель листа `Sheet`: merged-диапазоны, порядок ячеек строки, пустота, буквы колонок для диагностики."""
+
+import datetime
+from decimal import Decimal
+
+import pytest
+
+from src.parser.grid import EMPTY_CELL, Cell, MergedRange, Sheet, column_letter
+
+
+def _sheet(
+ cells: dict[tuple[int, int], Cell],
+ merged: tuple[MergedRange, ...] = (),
+ row_outline: dict[int, int] | None = None,
+) -> Sheet:
+ n_rows = max((row for row, _ in cells), default=0)
+ n_cols = max((col for _, col in cells), default=0)
+ return Sheet(name="ОСВ", cells=cells, merged=merged, row_outline=row_outline or {}, n_rows=n_rows, n_cols=n_cols)
+
+
+def test_text_at_inside_merged_range_takes_origin_value() -> None:
+ sheet = _sheet(
+ {(1, 2): Cell("Сальдо на начало периода", "text"), (2, 2): Cell("Дебет", "text")},
+ merged=(MergedRange(top=1, left=2, bottom=1, right=3),),
+ )
+
+ assert sheet.text_at(1, 3) == "Сальдо на начало периода"
+ assert sheet.text_at(1, 2) == "Сальдо на начало периода"
+ assert sheet.cell(1, 3) is EMPTY_CELL
+ assert sheet.merged_origin(1, 3) == (1, 2)
+ assert sheet.merged_span(1, 3) == MergedRange(1, 2, 1, 3)
+ assert sheet.text_at(2, 3) == ""
+ assert sheet.merged_origin(2, 3) == (2, 3)
+ assert sheet.merged_span(2, 3) is None
+
+
+def test_large_merged_range_is_resolved_without_cell_index() -> None:
+ # Диапазон на весь столбец: индекс «ячейка → диапазон» для него не строится, поиск всё равно работает
+ big = MergedRange(top=1, left=1, bottom=1_048_576, right=16_384)
+ sheet = _sheet({(1, 1): Cell("Шапка", "text")}, merged=(big,))
+
+ assert sheet.merged_origin(500_000, 10_000) == (1, 1)
+ assert sheet.text_at(1_048_576, 16_384) == "Шапка"
+
+
+def test_row_cells_are_ordered_by_column_and_skip_blank_text() -> None:
+ sheet = _sheet(
+ {
+ (3, 5): Cell(Decimal("10.5"), "number"),
+ (3, 1): Cell("60.01", "text"),
+ (3, 3): Cell(" ", "text"),
+ (4, 1): Cell("62", "text"),
+ }
+ )
+
+ assert sheet.row_cells(3) == [(1, Cell("60.01", "text")), (5, Cell(Decimal("10.5"), "number"))]
+ assert sheet.row_cells(7) == []
+
+
+def test_row_of_spaces_is_empty() -> None:
+ sheet = _sheet({(2, 1): Cell(" ", "text"), (2, 4): Cell("\xa0 ", "text"), (3, 1): Cell(False, "bool")})
+
+ assert sheet.is_row_empty(2)
+ assert sheet.is_row_empty(10)
+ assert not sheet.is_row_empty(3)
+
+
+def test_cell_text_strips_and_treats_spaces_as_empty() -> None:
+ assert Cell(" ", "text").text == ""
+ assert Cell(" Контрагент ", "text").text == "Контрагент"
+ assert Cell(None, "empty").text == ""
+ assert EMPTY_CELL.text == ""
+ assert Cell(Decimal("1250.00"), "number").text == "1250.00"
+ assert Cell(datetime.date(2024, 9, 30), "date").text == "2024-09-30"
+
+
+def test_outline_defaults_to_zero() -> None:
+ sheet = _sheet({(1, 1): Cell("62", "text")}, row_outline={2: 1, 3: 2})
+
+ assert [sheet.outline(row) for row in (1, 2, 3)] == [0, 1, 2]
+
+
+@pytest.mark.parametrize(
+ ("index", "letter"),
+ [(1, "A"), (26, "Z"), (27, "AA"), (52, "AZ"), (703, "AAA"), (16_384, "XFD")],
+)
+def test_column_letter(index: int, letter: str) -> None:
+ assert column_letter(index) == letter
+
+
+def test_column_letter_rejects_non_positive_index() -> None:
+ with pytest.raises(ValueError, match="1-based"):
+ column_letter(0)
diff --git a/api/tests/unit/parser/test_gridbuilder.py b/api/tests/unit/parser/test_gridbuilder.py
new file mode 100644
index 0000000..db3f217
--- /dev/null
+++ b/api/tests/unit/parser/test_gridbuilder.py
@@ -0,0 +1,56 @@
+"""Хелпер тестов `gridbuilder`: «картинка» строк превращается в `Sheet` так же, как его видят детекторы."""
+
+from decimal import Decimal
+
+import pytest
+from gridbuilder import C, L, M, at, sheet_from_rows
+
+from src.parser.grid import MergedRange
+
+
+def test_rows_and_columns_are_one_based_and_values_typed() -> None:
+ sheet = sheet_from_rows([["Счет", 100, Decimal("1.50")], [], [None, "x"]])
+
+ assert sheet.cell(1, 1).text == "Счет"
+ assert sheet.cell(1, 2).value == Decimal(100)
+ assert sheet.cell(1, 2).kind == "number"
+ assert sheet.cell(1, 3).value == Decimal("1.50")
+ assert sheet.cell(3, 2).text == "x"
+ assert (sheet.n_rows, sheet.n_cols) == (3, 3)
+
+
+def test_merged_cell_occupies_its_width_and_covers_rows_below() -> None:
+ sheet = sheet_from_rows([[M("Сальдо", 2, 2), "Обороты"], [None, None, "Дебет"]])
+
+ assert sheet.merged == (MergedRange(1, 1, 2, 2),)
+ assert sheet.cell(1, 3).text == "Обороты"
+ assert sheet.text_at(2, 2) == "Сальдо"
+ assert sheet.cell(2, 3).text == "Дебет"
+
+
+def test_value_under_merged_range_is_rejected() -> None:
+ with pytest.raises(ValueError, match="merged"):
+ sheet_from_rows([[M("Сальдо", 1, 2)], ["лишнее"]])
+
+
+def test_outline_style_and_sparse_rows() -> None:
+ sheet = sheet_from_rows([[L(2), C("60.01", bold=True, indent=3)], at({4: 7})])
+
+ assert [sheet.outline(1)] == [2]
+ assert sheet.outline(2) == 0
+ assert sheet.cell(1, 1).bold
+ assert [sheet.cell(1, 1).indent] == [3]
+ assert sheet.cell(2, 4).value == Decimal(7)
+ assert [sheet.n_cols] == [4]
+
+
+def test_whitespace_text_is_kept_as_a_cell_but_row_is_empty_for_detectors() -> None:
+ sheet = sheet_from_rows([[" ", "\u00a0"]])
+
+ assert (1, 1) in sheet.cells
+ assert sheet.is_row_empty(1)
+
+
+def test_float_is_rejected() -> None:
+ with pytest.raises(TypeError, match="float"):
+ sheet_from_rows([[0.1]])
diff --git a/api/tests/unit/parser/test_header.py b/api/tests/unit/parser/test_header.py
new file mode 100644
index 0000000..238fe7a
--- /dev/null
+++ b/api/tests/unit/parser/test_header.py
@@ -0,0 +1,480 @@
+"""Шапка документа и схема группировок (spec «Шапка документа и схема группировок», `05-domain-rules.md` §1.1, §1.5).
+
+Сетки собираются `gridbuilder`; шапка и заголовок таблицы находятся по содержимому, позиции в сетках произвольные.
+"""
+
+import datetime
+
+import pytest
+from gridbuilder import M, at, sheet_from_rows
+
+from src.parser.detect import TableHeader, find_table_header
+from src.parser.grid import Sheet
+from src.parser.header import DocHeader, build_group_schema, parse_doc_header
+from src.parser.rules import Rules, load_rules
+
+OPENING = "Сальдо на начало периода"
+TURNOVER = "Обороты за период"
+CLOSING = "Сальдо на конец периода"
+MISSING_FIELDS_WARNINGS = 5 # организация, счёт, период, тип данных, валюта
+VARIANT_2 = 2
+DATA_ROW = at({1: "60", 5: 100, 8: 20, 10: 30, 12: 40, 15: 50, 17: 60})
+SUBHEAD = ["Дебет", None, None, "Кредит", None, "Дебет", "Кредит", "Дебет", None, "Кредит"]
+GROUPS_ROW = [M("Счет", 4), M(OPENING, 5), M(TURNOVER, 5), M(CLOSING, 3)]
+
+
+@pytest.fixture(scope="module")
+def rules() -> Rules:
+ return load_rules()
+
+
+def _table(*label_rows: list[object]) -> list[list[object]]:
+ """Заголовок таблицы варианта 1: строка групп, подзаголовки сумм в строке первой метки, остальные метки ниже."""
+ first, *rest = label_rows
+ return [
+ GROUPS_ROW,
+ [*first, *at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"})[len(first) :]],
+ *rest,
+ DATA_ROW,
+ ]
+
+
+def _parse(rows: list[list[object]], rules: Rules) -> tuple[Sheet, TableHeader, DocHeader]:
+ sheet = sheet_from_rows(rows)
+ header = find_table_header(sheet, rules)
+ return sheet, header, parse_doc_header(sheet, header, rules)
+
+
+def _variant1(
+ rules: Rules, *, org: object = 'АО "Ромашка"', title: str, params: object = "Валюта БУ, НУ: RUB"
+) -> DocHeader:
+ rows: list[list[object]] = [
+ [org],
+ [title],
+ [],
+ ["Выводимые данные: БУ (данные бухгалтерского учета)"],
+ [],
+ ["Параметры:", None, params],
+ [],
+ *_table(["Контрагенты"]),
+ ]
+ return _parse(rows, rules)[2]
+
+
+def _title(period: str, account: str = "60") -> str:
+ return f"Оборотно-сальдовая ведомость по счету {account} за {period}"
+
+
+# --- Вариант 1: организация, счёт, период, тип данных, валюта ---
+
+
+def test_variant1_header_fields(rules: Rules) -> None:
+ header = _variant1(rules, title=_title("3 квартал 2024 г."), params="Валюта БУ, НУ: руб.")
+
+ assert header == DocHeader(
+ organization_name='АО "Ромашка"',
+ account_code_raw="60",
+ period_text="3 квартал 2024 г.",
+ period_year=2024,
+ period_quarter="Q3",
+ is_cumulative=False,
+ data_type="BU",
+ currency="RUB",
+ header_variant=1,
+ warnings=(),
+ )
+
+
+def test_account_with_subaccount_is_kept_as_in_file(rules: Rules) -> None:
+ header = _variant1(rules, title=_title("3 квартал 2024 г.", "58.03"))
+
+ assert header.account_code_raw == "58.03"
+
+
+@pytest.mark.parametrize("currency", ["RUB", "rub", "руб.", "Руб", "рублей"])
+def test_currency_spellings_give_rub(rules: Rules, currency: str) -> None:
+ header = _variant1(rules, title=_title("2024 г."), params=f"Валюта БУ, НУ: {currency}")
+
+ assert header.currency == "RUB"
+ assert not any("алют" in warning for warning in header.warnings)
+
+
+def test_other_currency_code_is_kept(rules: Rules) -> None:
+ header = _variant1(rules, title=_title("2024 г."), params="Валюта БУ, НУ: USD")
+
+ assert header.currency == "USD"
+
+
+def test_currency_value_in_neighbour_cell(rules: Rules) -> None:
+ rows: list[list[object]] = [
+ ["Организация"],
+ [_title("2024 г.")],
+ ["Параметры:", None, "Валюта БУ, НУ:", None, "RUB"],
+ *_table(["Контрагенты"]),
+ ]
+
+ assert _parse(rows, rules)[2].currency == "RUB"
+
+
+@pytest.mark.parametrize(
+ ("text", "expected"),
+ [
+ ("БУ (данные бухгалтерского учета)", "BU"),
+ ("НУ (данные налогового учета)", "NU"),
+ ("сумма", "SUM"),
+ ],
+)
+def test_data_type_values(rules: Rules, text: str, expected: str) -> None:
+ rows: list[list[object]] = [
+ ["Организация"],
+ [_title("2024 г.")],
+ [f"Выводимые данные: {text}"],
+ *_table(["Контрагенты"]),
+ ]
+
+ assert _parse(rows, rules)[2].data_type == expected
+
+
+# --- Период: все строки таблицы §1.5 ---
+
+
+@pytest.mark.parametrize(
+ ("period", "quarter", "cumulative"),
+ [
+ ("1 квартал 2025 г.", "Q1", True),
+ ("I кв. 2025 г.", "Q1", True),
+ ("3 месяца 2025 г.", "Q1", True),
+ ("январь-март 2025 г.", "Q1", True),
+ ("01.01.2025 - 31.03.2025", "Q1", True),
+ ("2 квартал 2025 г.", "Q2", False),
+ ("II кв. 2025 г.", "Q2", False),
+ ("полугодие 2025 г.", "Q2", True),
+ ("1 полугодие 2025 г.", "Q2", True),
+ ("6 месяцев 2025 г.", "Q2", True),
+ ("январь-июнь 2025 г.", "Q2", True),
+ ("3 квартал 2025 г.", "Q3", False),
+ ("III кв. 2025 г.", "Q3", False),
+ ("9 месяцев 2025 г.", "Q3", True),
+ ("январь-сентябрь 2025 г.", "Q3", True),
+ ("4 квартал 2025 г.", "Q4", False),
+ ("IV кв. 2025 г.", "Q4", False),
+ ("2025 год", "Q4", True),
+ ("2025 г.", "Q4", True),
+ ("12 месяцев 2025 г.", "Q4", True),
+ ("январь-декабрь 2025 г.", "Q4", True),
+ ("2025 год (годовая)", "Q4", True),
+ ],
+)
+def test_period_table_of_domain_rules(rules: Rules, period: str, quarter: str, cumulative: bool) -> None:
+ header = _variant1(rules, title=_title(period))
+
+ assert (header.period_quarter, header.is_cumulative, header.period_year) == (quarter, cumulative, 2025)
+ assert header.warnings == ()
+
+
+def test_period_text_ends_at_year_marker(rules: Rules) -> None:
+ header = _variant1(rules, title=_title("1 квартал 2025 г. (по подразделениям)"))
+
+ assert header.period_text == "1 квартал 2025 г."
+
+
+def test_period_without_year_marker_runs_to_line_end(rules: Rules) -> None:
+ header = _variant1(rules, title=_title("с 01.01.2025 по 31.03.2025"))
+
+ assert header.period_text == "с 01.01.2025 по 31.03.2025"
+ assert header.period_quarter == "Q1"
+
+
+def test_unrecognised_period_text_gives_none_and_warning(rules: Rules) -> None:
+ header = _variant1(rules, title=_title("неделю"))
+
+ assert header.period_text == "неделю"
+ assert (header.period_quarter, header.is_cumulative, header.period_year) == (None, None, None)
+ assert any("период" in warning for warning in header.warnings)
+
+
+# --- Отсутствующие поля шапки ---
+
+
+def test_missing_organization_gives_empty_string_and_warning(rules: Rules) -> None:
+ rows: list[list[object]] = [[_title("2024 г.")], ["Выводимые данные: БУ"], *_table(["Контрагенты"])]
+
+ header = _parse(rows, rules)[2]
+
+ assert header.organization_name == ""
+ assert any("организац" in warning for warning in header.warnings)
+ assert header.account_code_raw == "60"
+
+
+def test_empty_head_area_gives_empty_strings_none_and_warnings(rules: Rules) -> None:
+ header = _parse(_table(["Контрагенты"]), rules)[2]
+
+ assert header.organization_name == ""
+ assert header.account_code_raw == ""
+ assert header.period_text == ""
+ assert (header.period_year, header.period_quarter, header.is_cumulative) == (None, None, None)
+ assert (header.data_type, header.currency, header.header_variant) == (None, None, 1)
+ assert len(header.warnings) == MISSING_FIELDS_WARNINGS
+ assert all(warning for warning in header.warnings)
+
+
+def test_numeric_and_date_cells_in_head_do_not_break_parsing(rules: Rules) -> None:
+ rows: list[list[object]] = [
+ [45658], # xlsb: даты остаются числами
+ ["Организация", None, 45659],
+ [_title("2024 г.")],
+ *_table(["Контрагенты"]),
+ ]
+
+ header = _parse(rows, rules)[2]
+
+ assert header.organization_name == "Организация"
+ assert header.period_quarter == "Q4"
+
+
+def test_organization_is_first_text_cell_that_is_not_a_marker(rules: Rules) -> None:
+ rows: list[list[object]] = [
+ [],
+ ["Оборотно-сальдовая ведомость"],
+ ["Организация Б"],
+ ["Организация В"],
+ [_title("2024 г.")],
+ *_table(["Контрагенты"]),
+ ]
+
+ assert _parse(rows, rules)[2].organization_name == "Организация Б"
+
+
+# --- Схема группировок ---
+
+
+def test_group_schema_variant1_account_counterparty_contract(rules: Rules) -> None:
+ sheet, header, doc = _parse(
+ [["Организация"], [_title("2024 г.")], *_table(["Контрагенты"], ["Договоры"])],
+ rules,
+ )
+
+ groups, variant = build_group_schema(sheet, header, rules)
+
+ assert variant == doc.header_variant == 1
+ assert [(g.type, g.name, g.has_inn) for g in groups] == [
+ ("ACCOUNT", "Счет", None),
+ ("COUNTERPARTY", "Контрагенты", None),
+ ("CONTRACT", "Договоры", None),
+ ]
+
+
+def test_group_schema_inn_in_separate_cell_of_counterparty_row(rules: Rules) -> None:
+ rows: list[list[object]] = [
+ ["Организация"],
+ [_title("2024 г.")],
+ *_table(["Подразделение"], [M("Контрагенты", 2), "ИНН"]),
+ ]
+ sheet, header, _ = _parse(rows, rules)
+
+ groups, _ = build_group_schema(sheet, header, rules)
+
+ assert [(g.type, g.has_inn) for g in groups] == [("ACCOUNT", None), ("DEPARTMENT", None), ("COUNTERPARTY", True)]
+
+
+def test_group_schema_inn_in_the_label_cell(rules: Rules) -> None:
+ rows: list[list[object]] = [["Организация"], [_title("2024 г.")], *_table(["Контрагенты, ИНН"])]
+ sheet, header, _ = _parse(rows, rules)
+
+ groups, _ = build_group_schema(sheet, header, rules)
+
+ assert [(g.type, g.name, g.has_inn) for g in groups] == [
+ ("ACCOUNT", "Счет", None),
+ ("COUNTERPARTY", "Контрагенты, ИНН", True),
+ ]
+
+
+def test_first_group_is_account_whatever_its_text_says(rules: Rules) -> None:
+ rows: list[list[object]] = [
+ ["Организация"],
+ [_title("2024 г.")],
+ [M("Подразделение", 4), M(OPENING, 5), M(TURNOVER, 5), M(CLOSING, 3)],
+ ["Контрагенты", *at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"})[1:]],
+ DATA_ROW,
+ ]
+ sheet, header, _ = _parse(rows, rules)
+
+ groups, _ = build_group_schema(sheet, header, rules)
+
+ assert [(g.type, g.name) for g in groups] == [("ACCOUNT", "Подразделение"), ("COUNTERPARTY", "Контрагенты")]
+
+
+def test_unknown_group_text_is_other(rules: Rules) -> None:
+ rows: list[list[object]] = [["Организация"], [_title("2024 г.")], *_table(["Основные средства"])]
+ sheet, header, _ = _parse(rows, rules)
+
+ groups, _ = build_group_schema(sheet, header, rules)
+
+ assert [g.type for g in groups] == ["ACCOUNT", "OTHER"]
+
+
+def test_without_inn_cell_has_inn_stays_unset(rules: Rules) -> None:
+ rows: list[list[object]] = [["Организация"], [_title("2024 г.")], *_table(["Контрагенты"], ["Договоры"])]
+ sheet, header, _ = _parse(rows, rules)
+
+ groups, _ = build_group_schema(sheet, header, rules)
+
+ assert all(g.has_inn is None for g in groups)
+
+
+# --- Вариант 2 ---
+
+
+def _variant2_rows(detail: str = "Детализация по субсчетам, субконто: Контрагенты, Договоры") -> list[list[object]]:
+ return [
+ ["ООО Вторая"],
+ ["Оборотно-сальдовая ведомость по счету 60"],
+ ["Период: 1 квартал 2026 г."],
+ [detail],
+ ["Выводимые данные: сумма"],
+ [M("Субконто", 4), *GROUPS_ROW[1:]],
+ ["", *at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"})[1:]],
+ DATA_ROW,
+ ]
+
+
+def test_variant2_header(rules: Rules) -> None:
+ sheet, header, doc = _parse(_variant2_rows(), rules)
+
+ groups, variant = build_group_schema(sheet, header, rules)
+
+ assert variant == doc.header_variant == VARIANT_2
+ assert [(g.type, g.name) for g in groups] == [
+ ("ACCOUNT", "Субконто"),
+ ("COUNTERPARTY", "Контрагенты"),
+ ("CONTRACT", "Договоры"),
+ ]
+ assert doc.organization_name == "ООО Вторая"
+ assert doc.account_code_raw == "60"
+ assert (doc.period_text, doc.period_year, doc.period_quarter, doc.is_cumulative) == (
+ "1 квартал 2026 г.",
+ 2026,
+ "Q1",
+ True,
+ )
+ assert doc.data_type == "SUM"
+ assert doc.currency is None
+ assert any("алют" in warning for warning in doc.warnings)
+
+
+def test_variant2_single_item(rules: Rules) -> None:
+ sheet, header, _ = _parse(_variant2_rows("Детализация по субсчетам, субконто: Контрагенты"), rules)
+
+ groups, variant = build_group_schema(sheet, header, rules)
+
+ assert variant == VARIANT_2
+ assert [g.type for g in groups] == ["ACCOUNT", "COUNTERPARTY"]
+
+
+def test_variant2_detail_row_is_not_taken_for_organization(rules: Rules) -> None:
+ rows = _variant2_rows()
+ rows[0] = []
+
+ doc = _parse(rows, rules)[2]
+
+ assert doc.organization_name == ""
+
+
+# --- Название организации со словами «за» и «валюта» (T5.1) ---
+
+
+@pytest.mark.parametrize("organization", ["ООО «За Родину»", "Валюта Трейд", "За Урожай ЗАО"])
+def test_organization_name_with_marker_words_does_not_hijack_period_or_currency(
+ rules: Rules, organization: str
+) -> None:
+ header = _variant1(rules, org=organization, title=_title("3 квартал 2024 г."))
+
+ assert header.organization_name == organization
+ assert header.period_text == "3 квартал 2024 г."
+ assert (header.period_year, header.period_quarter, header.is_cumulative) == (2024, "Q3", False)
+ assert header.currency == "RUB"
+ assert header.warnings == ()
+
+
+def test_organization_with_currency_word_and_no_currency_row_is_still_organization(rules: Rules) -> None:
+ rows: list[list[object]] = [["Валюта Трейд"], [_title("2024 г.")], *_table(["Контрагенты"])]
+
+ header = _parse(rows, rules)[2]
+
+ assert header.organization_name == "Валюта Трейд"
+ assert header.currency is None
+ assert any("алют" in warning for warning in header.warnings)
+ assert header.period_quarter == "Q4"
+
+
+# --- Диапазоны дат с «г.» (T5.2) ---
+
+
+@pytest.mark.parametrize(
+ ("period", "quarter"),
+ [
+ ("01.01.2025 г. - 31.03.2025 г.", "Q1"),
+ ("с 01.01.2025 г. по 30.06.2025 г.", "Q2"),
+ ("01.01.2025 г. по 31.12.2025 г.", "Q4"),
+ ("01.01.2025 - 30.09.2025 г. (по подразделениям)", "Q3"),
+ ],
+)
+def test_date_range_with_year_marks_is_kept_whole(rules: Rules, period: str, quarter: str) -> None:
+ header = _variant1(rules, title=_title(period))
+
+ assert header.period_text == period.removesuffix(" (по подразделениям)")
+ assert (header.period_quarter, header.is_cumulative, header.period_year) == (quarter, True, 2025)
+ assert header.warnings == ()
+
+
+# --- Даты числами / датами (xlsb) (T5.5) ---
+
+
+def test_period_carried_only_by_date_cells_is_not_read(rules: Rules) -> None:
+ """Период «за» + ячейки-даты (xlsb не отдаёт форматы, даты — числа): текста периода нет, это не ошибка разбора."""
+ rows: list[list[object]] = [
+ ["Организация"],
+ ["Оборотно-сальдовая ведомость по счету 60 за", datetime.date(2025, 1, 1), datetime.date(2025, 3, 31)],
+ *_table(["Контрагенты"]),
+ ]
+
+ header = _parse(rows, rules)[2]
+
+ assert header.organization_name == "Организация"
+ assert header.account_code_raw == "60"
+ assert header.period_text == ""
+ assert (header.period_year, header.period_quarter, header.is_cumulative) == (None, None, None)
+ assert any("период" in warning for warning in header.warnings)
+
+
+# --- Вариант 2: ИНН (T5.4) ---
+
+
+def _variant2_with_inn_column() -> list[list[object]]:
+ rows = _variant2_rows()
+ rows[6] = [
+ "",
+ None,
+ "ИНН",
+ None,
+ *at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"})[4:],
+ ]
+ return rows
+
+
+def test_variant2_inn_column_marks_the_first_counterparty(rules: Rules) -> None:
+ sheet, header, _ = _parse(_variant2_with_inn_column(), rules)
+
+ groups, _ = build_group_schema(sheet, header, rules)
+
+ assert header.inn_col is not None
+ assert [(g.type, g.has_inn) for g in groups] == [("ACCOUNT", None), ("COUNTERPARTY", True), ("CONTRACT", None)]
+
+
+def test_variant2_without_inn_column_leaves_has_inn_unset(rules: Rules) -> None:
+ sheet, header, _ = _parse(_variant2_rows(), rules)
+
+ groups, _ = build_group_schema(sheet, header, rules)
+
+ assert header.inn_col is None
+ assert all(g.has_inn is None for g in groups)
diff --git a/api/tests/unit/parser/test_hierarchy.py b/api/tests/unit/parser/test_hierarchy.py
new file mode 100644
index 0000000..7a15156
--- /dev/null
+++ b/api/tests/unit/parser/test_hierarchy.py
@@ -0,0 +1,458 @@
+"""Каскад стратегий иерархии строк (spec «Иерархия строк — каскад стратегий», design D-3, §1.0 п. 5, §1.3).
+
+Строки данных — `BodyRow` (их строит классификатор Задачи 8); номера строк в тестах произвольные: стратегии и
+`parents` работают по порядку и уровням строк, а не по номерам или текстам.
+"""
+
+import re
+from collections.abc import Sequence
+from decimal import Decimal
+
+import pytest
+
+from src.parser.deadline import Deadline
+from src.parser.errors import InvalidFormatError, ParseTimeoutError
+from src.parser.hierarchy import (
+ DEADLINE_CHECK_EVERY,
+ STRATEGIES,
+ TRIVIAL_STRATEGY,
+ BodyRow,
+ Levels,
+ Strategy,
+ is_consistent,
+ parents,
+ resolve_levels,
+ strategy_account_pattern,
+ strategy_indent,
+ strategy_outline,
+ strategy_sums,
+)
+
+ACCOUNT = re.compile(r"^(\d{2,3}(?:\.\d{1,2}){0,2})(?:,\s*.*)?$")
+NO_SUMS: tuple[Decimal | None, ...] = (None,) * 6
+FIRST = 17 # произвольное начало данных: стратегии не знают номеров строк
+
+
+def money(*values: str | None) -> tuple[Decimal | None, ...]:
+ assert len(values) == len(NO_SUMS)
+ return tuple(None if value is None else Decimal(value) for value in values)
+
+
+def body( # noqa: PLR0913 - фабрика тестовых строк: все признаки оформления именованные
+ row: int,
+ text: str,
+ *,
+ outline: int = 0,
+ indent: int = 0,
+ bold: bool = False,
+ sums: tuple[Decimal | None, ...] = NO_SUMS,
+) -> BodyRow:
+ match = ACCOUNT.fullmatch(text)
+ rank = match.group(1).count(".") + 1 if match else None
+ return BodyRow(
+ row=row,
+ text=text,
+ is_account=match is not None,
+ account_rank=rank,
+ bold=bold,
+ indent=indent,
+ outline=outline,
+ sums=sums,
+ )
+
+
+def numbered(*specs: tuple[str, dict[str, object]]) -> list[BodyRow]:
+ """Строки подряд с произвольного номера `FIRST`."""
+ return [body(FIRST + offset, text, **options) for offset, (text, options) in enumerate(specs)] # type: ignore[arg-type]
+
+
+def levels_list(rows: Sequence[BodyRow], by_row: dict[int, int]) -> list[int]:
+ return [by_row[item.row] for item in rows]
+
+
+def never_expires() -> Deadline:
+ return Deadline(3600)
+
+
+# --- Каскад: какая стратегия выбирается ---
+
+
+def test_strategies_are_ordered_outline_indent_account_pattern_sums() -> None:
+ assert [name for name, _ in STRATEGIES] == ["outline", "indent", "account_pattern", "sums"]
+
+
+def test_outline_levels_win_first() -> None:
+ rows = numbered(
+ ("66", {"outline": 0, "indent": 3}),
+ ("66.01", {"outline": 1}),
+ ("Юг-Финанс", {"outline": 2}),
+ ("66.03", {"outline": 1}),
+ ("Юг-Финанс", {"outline": 2}),
+ )
+
+ levels = resolve_levels(rows, None, never_expires())
+
+ assert levels.strategy == "outline"
+ assert levels_list(rows, levels.by_row) == [0, 1, 2, 1, 2]
+ assert levels.parent == {17: None, 18: 17, 19: 18, 20: 17, 21: 20}
+
+
+def test_outline_is_shifted_to_zero_when_table_is_grouped_entirely() -> None:
+ rows = numbered(("60", {"outline": 1}), ("60.01", {"outline": 2}), ("60.02", {"outline": 2}))
+
+ assert strategy_outline(rows) == {17: 0, 18: 1, 19: 1}
+
+
+def test_outline_with_single_level_is_not_applicable() -> None:
+ rows = numbered(("60", {"outline": 0}), ("60.01", {"outline": 0}))
+
+ assert strategy_outline(rows) is None
+
+
+def test_indent_is_used_when_outline_is_flat() -> None:
+ rows = numbered(
+ ("Расчеты", {"indent": 0}),
+ ("Поставщики", {"indent": 2}),
+ ("ООО Ромашка", {"indent": 4}),
+ ("Покупатели", {"indent": 2}),
+ )
+
+ levels = resolve_levels(rows, None, never_expires())
+
+ assert levels.strategy == "indent"
+ assert levels_list(rows, levels.by_row) == [0, 1, 2, 1]
+
+
+@pytest.mark.parametrize(
+ "indents",
+ [(0, 2, 4), (0, 1, 2), (0, 3, 7)],
+ ids=["leading-spaces", "alignment-indent", "uneven-steps"],
+)
+def test_indent_levels_are_ranks_of_distinct_values_in_any_units(indents: tuple[int, int, int]) -> None:
+ top, middle, deep = indents
+ rows = numbered(
+ ("Группа", {"indent": top}),
+ ("Подгруппа", {"indent": middle}),
+ ("Строка", {"indent": deep}),
+ ("Строка 2", {"indent": deep}),
+ ("Группа 2", {"indent": top}),
+ )
+
+ assert strategy_indent(rows) == {17: 0, 18: 1, 19: 2, 20: 2, 21: 0}
+
+
+def test_indent_with_single_value_is_not_applicable() -> None:
+ rows = numbered(("Группа", {"indent": 2}), ("Строка", {"indent": 2}))
+
+ assert strategy_indent(rows) is None
+
+
+def test_account_pattern_when_no_outline_and_no_indent() -> None:
+ rows = numbered(("60", {}), ("60.01", {}), ("ООО Ромашка", {}), ("60.02", {}), ("ООО Лютик", {}))
+
+ levels = resolve_levels(rows, None, never_expires())
+
+ assert levels.strategy == "account_pattern"
+ assert levels_list(rows, levels.by_row) == [0, 1, 2, 1, 2]
+
+
+def test_account_pattern_levels_are_relative_to_minimal_rank() -> None:
+ rows = numbered(("60.01", {}), ("60.01.1", {}), ("Контрагент", {}), ("60.02", {}))
+
+ assert strategy_account_pattern(rows) == {17: 0, 18: 1, 19: 2, 20: 0}
+
+
+def test_account_pattern_account_with_name_after_comma_is_an_account() -> None:
+ rows = numbered(("60", {}), ("60.01, Расчеты с поставщиками", {}), ("ООО Ромашка", {}))
+
+ assert strategy_account_pattern(rows) == {17: 0, 18: 1, 19: 2}
+
+
+def test_account_pattern_bold_groups_and_their_plain_details() -> None:
+ rows = numbered(
+ ("60", {"bold": True}),
+ ("60.01", {"bold": True}),
+ ("ООО Ромашка", {"bold": True}),
+ ("Договор 1", {}),
+ ("Договор 2", {}),
+ ("ООО Лютик", {"bold": True}),
+ ("Договор 3", {}),
+ ("60.02", {"bold": True}),
+ ("ИП Иванов", {}),
+ )
+
+ assert levels_list(rows, strategy_account_pattern(rows) or {}) == [0, 1, 2, 3, 3, 2, 3, 1, 2]
+
+
+def test_account_pattern_without_accounts_is_not_applicable() -> None:
+ rows = numbered(("ООО Ромашка", {}), ("Договор", {}))
+
+ assert strategy_account_pattern(rows) is None
+
+
+def test_sums_parent_equals_sum_of_two_children() -> None:
+ rows = numbered(
+ ("Расчеты", {"sums": money("100.00", None, "50.00", "30.00", "120.00", None)}),
+ ("ООО Ромашка", {"sums": money("60.00", None, "50.00", None, "110.00", None)}),
+ ("ООО Лютик", {"sums": money("40.00", None, None, "30.00", "10.00", None)}),
+ )
+
+ levels = resolve_levels(rows, None, never_expires())
+
+ assert levels.strategy == "sums"
+ assert levels_list(rows, levels.by_row) == [0, 1, 1]
+ assert levels.parent == {17: None, 18: 17, 19: 17}
+
+
+def test_sums_builds_nested_tree_and_several_roots() -> None:
+ rows = numbered(
+ ("Счёт", {"sums": money("100.00", None, None, None, "100.00", None)}),
+ ("Субсчёт А", {"sums": money("70.00", None, None, None, "70.00", None)}),
+ ("Контрагент 1", {"sums": money("30.00", None, None, None, "30.00", None)}),
+ ("Контрагент 2", {"sums": money("40.00", None, None, None, "40.00", None)}),
+ ("Субсчёт Б", {"sums": money("30.00", None, None, None, "30.00", None)}),
+ ("Контрагент 3", {"sums": money("30.00", None, None, None, "30.00", None)}),
+ ("Другой счёт", {"sums": money(None, "5.00", None, None, None, "5.00")}),
+ ("Контрагент 4", {"sums": money(None, "5.00", None, None, None, "5.00")}),
+ )
+
+ assert levels_list(rows, strategy_sums(rows) or {}) == [0, 1, 2, 2, 1, 2, 0, 1]
+
+
+def test_sums_without_any_matching_parent_is_not_applicable() -> None:
+ rows = numbered(
+ ("А", {"sums": money("1.00", None, None, None, "1.00", None)}),
+ ("Б", {"sums": money("2.00", None, None, None, "2.00", None)}),
+ )
+
+ assert strategy_sums(rows) is None
+
+
+def test_inconsistent_outline_is_rejected_and_next_strategy_is_used() -> None:
+ # outline даёт первой строке уровень 1 — ребёнок без родителя; отступы дают корректное дерево
+ rows = numbered(
+ ("Группа", {"outline": 1, "indent": 0}),
+ ("Строка", {"outline": 0, "indent": 2}),
+ ("Строка 2", {"outline": 1, "indent": 2}),
+ )
+
+ levels = resolve_levels(rows, None, never_expires())
+
+ assert levels.strategy == "indent"
+ assert levels_list(rows, levels.by_row) == [0, 1, 1]
+
+
+def test_no_strategy_fits_raises_invalid_format() -> None:
+ rows = numbered(("Строка", {}), ("Строка 2", {}))
+
+ with pytest.raises(InvalidFormatError, match="иерархия строк не определена"):
+ resolve_levels(rows, None, never_expires())
+
+
+# --- «Итого» ---
+
+
+def test_total_is_level_zero_without_parent_even_if_formatted_deeper() -> None:
+ rows = numbered(
+ ("62", {"outline": 0}),
+ ("62.01", {"outline": 1}),
+ ("Покупатель", {"outline": 2}),
+ ("Итого", {"outline": 2, "indent": 4}),
+ )
+ total = rows[-1].row
+
+ levels = resolve_levels(rows, total, never_expires())
+
+ assert levels.strategy == "outline"
+ assert levels.by_row[total] == 0
+ assert levels.parent[total] is None
+
+
+def test_total_is_not_a_child_of_last_account_in_account_pattern() -> None:
+ rows = numbered(("60", {}), ("60.01", {}), ("ООО Ромашка", {}), ("Итого", {}))
+ total = rows[-1].row
+
+ levels = resolve_levels(rows, total, never_expires())
+
+ assert levels.strategy == "account_pattern"
+ assert levels_list(rows, levels.by_row) == [0, 1, 2, 0]
+ assert levels.parent[total] is None
+
+
+def test_total_does_not_break_sums_strategy() -> None:
+ rows = numbered(
+ ("Расчеты", {"sums": money("10.00", None, None, None, "10.00", None)}),
+ ("А", {"sums": money("4.00", None, None, None, "4.00", None)}),
+ ("Б", {"sums": money("6.00", None, None, None, "6.00", None)}),
+ ("Итого", {"sums": money("10.00", None, None, None, "10.00", None)}),
+ )
+ total = rows[-1].row
+
+ levels = resolve_levels(rows, total, never_expires())
+
+ assert levels.strategy == "sums"
+ assert levels_list(rows, levels.by_row) == [0, 1, 1, 0]
+
+
+def test_table_with_only_total_has_trivial_hierarchy() -> None:
+ rows = numbered(("Итого", {"sums": money("0.00", None, None, None, "0.00", None)}))
+
+ levels = resolve_levels(rows, rows[0].row, never_expires())
+
+ assert levels == Levels(by_row={17: 0}, parent={17: None}, strategy=TRIVIAL_STRATEGY)
+
+
+def test_single_row_without_total_has_trivial_hierarchy() -> None:
+ # пустая ОСВ, где «Итого» без сумм не распознано как TOTAL: одна строка — дерево из одного корня
+ rows = numbered(("Итого", {}))
+
+ levels = resolve_levels(rows, None, never_expires())
+
+ assert levels == Levels(by_row={17: 0}, parent={17: None}, strategy=TRIVIAL_STRATEGY)
+
+
+def test_single_body_row_with_total_has_trivial_hierarchy() -> None:
+ rows = numbered(("58", {"outline": 1}), ("Итого", {}))
+
+ levels = resolve_levels(rows, rows[-1].row, never_expires())
+
+ assert levels == Levels(by_row={17: 0, 18: 0}, parent={17: None, 18: None}, strategy=TRIVIAL_STRATEGY)
+
+
+def test_empty_table_has_trivial_hierarchy() -> None:
+ assert resolve_levels([], None, never_expires()) == Levels(by_row={}, parent={}, strategy=TRIVIAL_STRATEGY)
+
+
+# --- is_consistent ---
+
+
+def test_is_consistent_accepts_tree_with_root_first_and_total_zero() -> None:
+ rows = numbered(("60", {}), ("60.01", {}), ("А", {}), ("Итого", {}))
+
+ assert is_consistent(rows, {17: 0, 18: 1, 19: 2, 20: 0}, 20)
+
+
+@pytest.mark.parametrize(
+ ("by_row", "total"),
+ [
+ ({17: 1, 18: 1, 19: 2, 20: 0}, 20), # первая строка не корень
+ ({17: 0, 18: 1, 19: 2, 20: 1}, 20), # «Итого» не на уровне 0
+ ({17: 0, 18: 1, 19: 2}, 20), # у «Итого» нет уровня
+ ({17: 0, 18: 1, 20: 0}, 20), # у строки нет уровня
+ ({17: 0, 18: -1, 19: 0, 20: 0}, 20), # отрицательный уровень
+ ],
+ ids=["first-not-root", "total-nested", "total-missing", "row-missing", "negative"],
+)
+def test_is_consistent_rejects_broken_trees(by_row: dict[int, int], total: int) -> None:
+ rows = numbered(("60", {}), ("60.01", {}), ("А", {}), ("Итого", {}))
+
+ assert not is_consistent(rows, by_row, total)
+
+
+def test_is_consistent_without_total() -> None:
+ rows = numbered(("60", {}), ("А", {}))
+
+ assert is_consistent(rows, {17: 0, 18: 1}, None)
+
+
+# --- parents: по структуре, не по имени (Review Focus 3) ---
+
+
+def test_parents_are_nearest_previous_rows_with_smaller_level() -> None:
+ rows = numbered(("60", {}), ("60.01", {}), ("А", {}), ("Договор", {}), ("Б", {}), ("62", {}), ("В", {}))
+ by_row = {17: 0, 18: 1, 19: 2, 20: 3, 21: 2, 22: 0, 23: 1}
+
+ assert parents(rows, by_row) == {17: None, 18: 17, 19: 18, 20: 19, 21: 18, 22: None, 23: 22}
+
+
+def test_same_named_counterparties_under_different_subaccounts_have_different_parents() -> None:
+ rows = numbered(
+ ("66", {"outline": 0}),
+ ("66.01", {"outline": 1}),
+ ("Юг-Финанс", {"outline": 2}),
+ ("66.03", {"outline": 1}),
+ ("Юг-Финанс", {"outline": 2}),
+ )
+ first, second = (item.row for item in rows if item.text == "Юг-Финанс")
+
+ for by_row in (strategy_outline(rows), strategy_account_pattern(rows)):
+ assert by_row is not None
+ tree = parents(rows, by_row)
+ assert tree[first] == rows[1].row # 66.01
+ assert tree[second] == rows[3].row # 66.03
+
+
+# --- Дедлайн ---
+
+
+def test_expired_deadline_raises_timeout() -> None:
+ now = [0.0]
+ deadline = Deadline(5, clock=lambda: now[0])
+ now[0] = 6.0
+ rows = numbered(("60", {"outline": 0}), ("60.01", {"outline": 1}))
+
+ with pytest.raises(ParseTimeoutError, match="иерархия"):
+ resolve_levels(rows, None, deadline)
+
+
+CLOCK_READS_CREATE_AND_ONE_CHECK = 2 # чтение часов при создании Deadline + одна проверка на DEADLINE_CHECK_EVERY строк
+
+
+class TickingClock:
+ """Часы, которые сдвигаются на 1 с при каждом обращении: дедлайн истекает на заданной по счёту проверке."""
+
+ def __init__(self) -> None:
+ self.calls = 0
+
+ def __call__(self) -> float:
+ value = float(self.calls)
+ self.calls += 1
+ return value
+
+
+@pytest.mark.parametrize("expired_at", range(len(STRATEGIES)), ids=[name for name, _ in STRATEGIES])
+def test_deadline_is_checked_before_each_strategy(expired_at: int) -> None:
+ # ни одна стратегия не подходит, каскад проходит все четыре; строк меньше DEADLINE_CHECK_EVERY — проверки
+ # только между стратегиями: создание — t=0, перед k-й стратегией — t=k
+ rows = numbered(("Строка", {}), ("Строка 2", {}))
+ name = STRATEGIES[expired_at][0]
+
+ with pytest.raises(ParseTimeoutError) as raised:
+ resolve_levels(rows, None, Deadline(expired_at, clock=TickingClock()))
+
+ assert raised.value.message == f"превышен лимит {expired_at} с на шаге иерархия ({name})"
+
+
+def _never_matching_sums(count: int) -> list[BodyRow]:
+ """Строки без outline/отступов/счетов с растущими суммами: ни одна не равна сумме следующих — худший случай sums."""
+ return [
+ body(FIRST + offset, f"Строка {offset}", sums=money(str(offset + 1), None, None, None, None, None))
+ for offset in range(count)
+ ]
+
+
+def test_deadline_expires_inside_sums_scan() -> None:
+ rows = _never_matching_sums(60) # ≈ 1800 шагов перебора > DEADLINE_CHECK_EVERY, строк < DEADLINE_CHECK_EVERY
+ # четыре проверки между стратегиями (t=1…4) проходят; следующая — уже внутри перебора sums
+ deadline = Deadline(len(STRATEGIES), clock=TickingClock())
+
+ with pytest.raises(ParseTimeoutError, match=r"иерархия \(sums\): перебор поддеревьев"):
+ resolve_levels(rows, None, deadline)
+
+
+def test_sums_without_deadline_finishes_worst_case() -> None:
+ assert strategy_sums(_never_matching_sums(60)) is None
+
+
+@pytest.mark.parametrize(
+ ("strategy", "name"),
+ [(strategy_outline, "outline"), (strategy_indent, "indent"), (strategy_account_pattern, "account_pattern")],
+)
+def test_linear_strategies_check_deadline_every_n_rows(strategy: Strategy, name: str) -> None:
+ rows = [body(FIRST + offset, "60") for offset in range(DEADLINE_CHECK_EVERY)]
+ clock = TickingClock()
+ deadline = Deadline(0, clock=clock) # истекает на первой же проверке
+
+ with pytest.raises(ParseTimeoutError, match=rf"иерархия \({name}\)"):
+ strategy(rows, deadline)
+ assert clock.calls == CLOCK_READS_CREATE_AND_ONE_CHECK
diff --git a/api/tests/unit/parser/test_no_coordinates.py b/api/tests/unit/parser/test_no_coordinates.py
new file mode 100644
index 0000000..bb0d5d6
--- /dev/null
+++ b/api/tests/unit/parser/test_no_coordinates.py
@@ -0,0 +1,382 @@
+"""AST-линтер «без координат» (design D-10, ruling R-7): в `src/parser/**` кроме `grid.py` и `readers/` нет адресов
+ячеек и букв колонок, имён листов по умолчанию, сравнений с номерами строк/колонок и индексации `cells[(3, 4)]`.
+
+Структуру файла парсер ищет по содержимому (§1.0 `05-domain-rules.md`), синонимы живут в `rules/*.yaml`. Если линтер
+падает на коде, нужен не `noqa`, а другой алгоритм (по содержимому) или синоним в словаре.
+"""
+
+import ast
+import enum
+import importlib
+import pkgutil
+import re
+import typing
+from collections.abc import Iterator
+from dataclasses import dataclass
+from pathlib import Path
+
+import pytest
+from pydantic import BaseModel
+
+import src.contract
+import src.parser
+
+PARSER_ROOT = Path(src.parser.__file__).resolve().parent
+EXCLUDED_FILES = frozenset({"grid.py"})
+EXCLUDED_DIRS = frozenset({"readers"})
+
+# (а) адреса и буквы колонок: «E9», «A», «AB», «XFD1»
+ADDRESS_LITERAL = re.compile(r"^[A-Z]{1,3}\d{0,7}$")
+ADDRESS_MAX_LENGTH = 10 # «XFD1048576» — самый длинный адрес Excel; T3.1: прежний предел 4 пропускал «AB123», «E1000»
+# (б) имя листа по умолчанию целиком (R-4): сообщения вроде «лист ОСВ без заголовка» разрешены
+DEFAULT_SHEET_NAME = re.compile(r"(?i)^(лист|sheet)[\s_]*\d*$")
+# (в) сравнение с номером строки/колонки
+COORDINATE_NAMES = frozenset({"row", "row_number", "r", "col", "column"})
+# (г) обращение к ячейке листа двумя числовыми константами: `sheet.cell(3, 4)`, `sheet.text_at(3, 4)` (T3.8)
+CELL_ACCESSORS = frozenset({"cell", "text_at"})
+CELL_ARGUMENTS = 2 # строка и колонка
+
+
+@dataclass(frozen=True)
+class Violation:
+ path: Path
+ line: int
+ message: str
+
+ def __str__(self) -> str:
+ return f"{self.path}:{self.line}: {self.message}"
+
+
+def contract_values() -> frozenset[str]:
+ """Строковые значения контракта: все StrEnum и Literal-аргументы моделей и алиасов `src.contract` (R-5).
+
+ Это структура результата («BU», «Q1», «XLSX», коды ошибок), а не координаты; вычисляется программно.
+ """
+ values: set[str] = set()
+
+ def walk(annotation: object) -> None:
+ if typing.get_origin(annotation) is typing.Literal:
+ values.update(arg for arg in typing.get_args(annotation) if isinstance(arg, str))
+ return
+ for arg in typing.get_args(annotation):
+ walk(arg)
+
+ for module_info in pkgutil.iter_modules(src.contract.__path__, prefix=f"{src.contract.__name__}."):
+ module = importlib.import_module(module_info.name)
+ for attribute in vars(module).values():
+ if isinstance(attribute, type) and issubclass(attribute, enum.StrEnum):
+ values.update(member.value for member in attribute)
+ elif isinstance(attribute, type) and issubclass(attribute, BaseModel):
+ for field in attribute.model_fields.values():
+ walk(field.annotation)
+ else:
+ walk(attribute)
+ return frozenset(values)
+
+
+def _is_literal_subscript(node: ast.AST) -> bool:
+ if not isinstance(node, ast.Subscript):
+ return False
+ target = node.value
+ return (isinstance(target, ast.Name) and target.id == "Literal") or (
+ isinstance(target, ast.Attribute) and target.attr == "Literal"
+ )
+
+
+def _annotation_nodes(tree: ast.AST) -> Iterator[ast.AST]:
+ """Аннотации аргументов, возвратов, AnnAssign и любые `Literal[...]` (в том числе в алиасах типов)."""
+ for node in ast.walk(tree):
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
+ arguments = node.args
+ every_arg = [*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs]
+ every_arg += [arg for arg in (arguments.vararg, arguments.kwarg) if arg is not None]
+ for arg in every_arg:
+ if arg.annotation is not None:
+ yield arg.annotation
+ if node.returns is not None:
+ yield node.returns
+ elif isinstance(node, ast.AnnAssign):
+ yield node.annotation
+ elif _is_literal_subscript(node):
+ yield node
+
+
+def _exempt_constants(tree: ast.AST) -> set[int]:
+ return {
+ id(inner) for root in _annotation_nodes(tree) for inner in ast.walk(root) if isinstance(inner, ast.Constant)
+ }
+
+
+def _is_coordinate_name(node: ast.AST) -> bool:
+ return (isinstance(node, ast.Name) and node.id in COORDINATE_NAMES) or (
+ isinstance(node, ast.Attribute) and node.attr in COORDINATE_NAMES
+ )
+
+
+def _is_int_constant(node: ast.AST) -> bool:
+ return isinstance(node, ast.Constant) and isinstance(node.value, int) and not isinstance(node.value, bool)
+
+
+def _is_constant_cell_call(node: ast.Call) -> bool:
+ """`cell(3, 4)` / `x.text_at(3, 4)` / `x.cell(row=3, col=4)`: строка и колонка заданы числовыми константами."""
+ func = node.func
+ name = func.id if isinstance(func, ast.Name) else func.attr if isinstance(func, ast.Attribute) else None
+ if name not in CELL_ACCESSORS:
+ return False
+ arguments = [*node.args, *(keyword.value for keyword in node.keywords)]
+ return len(arguments) >= CELL_ARGUMENTS and all(
+ _is_int_constant(argument) for argument in arguments[:CELL_ARGUMENTS]
+ )
+
+
+def _violations_in_tree(path: Path, tree: ast.AST, allowed_strings: frozenset[str]) -> Iterator[Violation]:
+ exempt = _exempt_constants(tree)
+ for node in ast.walk(tree):
+ if isinstance(node, ast.Constant) and isinstance(node.value, str) and id(node) not in exempt:
+ text = node.value
+ if len(text) <= ADDRESS_MAX_LENGTH and ADDRESS_LITERAL.fullmatch(text) and text not in allowed_strings:
+ yield Violation(path, node.lineno, f"литерал {text!r} похож на адрес ячейки или букву колонки")
+ elif DEFAULT_SHEET_NAME.fullmatch(text):
+ yield Violation(path, node.lineno, f"литерал {text!r} — имя листа по умолчанию")
+ elif isinstance(node, ast.Compare):
+ operands = [node.left, *node.comparators]
+ pairs = zip(operands, operands[1:], strict=False)
+ if any(
+ (_is_coordinate_name(left) and _is_int_constant(right))
+ or (_is_coordinate_name(right) and _is_int_constant(left))
+ for left, right in pairs
+ ):
+ yield Violation(path, node.lineno, "сравнение номера строки/колонки с числовой константой")
+ elif isinstance(node, ast.Call) and _is_constant_cell_call(node):
+ yield Violation(path, node.lineno, "обращение к ячейке константами (строка, колонка)")
+ elif (
+ isinstance(node, ast.Subscript)
+ and not _is_literal_subscript(node) # Literal[1, 2] — значения типа контракта, не индекс ячейки
+ and isinstance(node.slice, ast.Tuple)
+ and len(node.slice.elts) == 2 # noqa: PLR2004 - пара (строка, колонка)
+ and all(_is_int_constant(element) for element in node.slice.elts)
+ ):
+ yield Violation(path, node.lineno, "индексация ячейки константами (строка, колонка)")
+
+
+def _parser_files(root: Path) -> Iterator[Path]:
+ for path in sorted(root.rglob("*.py")):
+ relative = path.relative_to(root)
+ if path.name in EXCLUDED_FILES and len(relative.parts) == 1:
+ continue
+ if relative.parts[0] in EXCLUDED_DIRS and len(relative.parts) > 1:
+ continue
+ yield path
+
+
+def find_violations(root: Path, allowed_strings: frozenset[str] | None = None) -> list[Violation]:
+ allowed = contract_values() if allowed_strings is None else allowed_strings
+ found: list[Violation] = []
+ for path in _parser_files(root):
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ found.extend(_violations_in_tree(path, tree, allowed))
+ return sorted(found, key=lambda violation: (str(violation.path), violation.line))
+
+
+def _tree(tmp_path: Path, files: dict[str, str]) -> Path:
+ root = tmp_path / "parser"
+ for name, code in files.items():
+ target = root / name
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(code, encoding="utf-8")
+ return root
+
+
+# --- Реальное дерево ---
+
+
+def test_parser_sources_have_no_coordinates() -> None:
+ violations = find_violations(PARSER_ROOT)
+
+ assert violations == [], "\n".join(str(violation) for violation in violations)
+
+
+def test_linter_scans_parser_modules_and_skips_grid_and_readers() -> None:
+ scanned = {path.relative_to(PARSER_ROOT).as_posix() for path in _parser_files(PARSER_ROOT)}
+
+ assert "rules.py" in scanned
+ assert "errors.py" in scanned
+ assert "grid.py" not in scanned
+ assert not any(name.startswith("readers/") for name in scanned)
+
+
+def test_contract_values_are_computed_from_src_contract() -> None:
+ values = contract_values()
+
+ assert {"BU", "NU", "SUM", "CURRENCY", "QUANTITY", "Q1", "Q4", "XLS", "XLSX", "XLSB"} <= values
+ assert {"ACCOUNT", "COUNTERPARTY", "UNREADABLE", "INVALID_FORMAT"} <= values
+
+
+# --- Негативные тесты на временном дереве ---
+
+
+def test_cell_address_literal_is_caught_with_file_and_line(tmp_path: Path) -> None:
+ root = _tree(tmp_path, {"detect.py": 'x = 1\ntarget = "E9"\n'})
+
+ (violation,) = find_violations(root)
+
+ assert violation.path == root / "detect.py"
+ assert [violation.line] == [2]
+ assert "detect.py:2" in str(violation)
+ assert "'E9'" in violation.message
+
+
+@pytest.mark.parametrize("literal", ["A", "AB", "AZ12", "XFD1", "AB123", "E1000", "ZZ99999", "XFD1048576"])
+def test_column_letters_are_caught(tmp_path: Path, literal: str) -> None:
+ root = _tree(tmp_path, {"detect.py": f'letter = "{literal}"\n'})
+
+ assert len(find_violations(root)) == 1
+
+
+def test_default_sheet_name_literal_is_caught(tmp_path: Path) -> None:
+ root = _tree(tmp_path, {"detect.py": 'x = "Лист1"\ny = "Sheet 2"\nz = "лист_1"\n'})
+
+ assert [violation.line for violation in find_violations(root)] == [1, 2, 3]
+
+
+def test_error_message_mentioning_sheet_is_allowed(tmp_path: Path) -> None:
+ root = _tree(tmp_path, {"detect.py": 'message = "лист ОСВ без заголовка"\nother = "листов просмотрено 3"\n'})
+
+ assert find_violations(root) == []
+
+
+@pytest.mark.parametrize("name", ["row", "row_number", "r", "col", "column"])
+def test_comparison_of_row_with_number_is_caught(tmp_path: Path, name: str) -> None:
+ root = _tree(tmp_path, {"detect.py": f"def f({name}):\n if {name} == 5:\n return 1\n"})
+
+ (violation,) = find_violations(root)
+
+ assert [violation.line] == [2]
+
+
+def test_comparison_with_attribute_and_reversed_operands_is_caught(tmp_path: Path) -> None:
+ root = _tree(tmp_path, {"detect.py": "a = item.row < 3\nb = 7 >= header.col\nc = 1 < r < 10\n"})
+
+ assert [violation.line for violation in find_violations(root)] == [1, 2, 3]
+
+
+def test_comparison_of_other_names_and_non_int_is_allowed(tmp_path: Path) -> None:
+ code = "a = count == 5\nb = row == other_row\nc = row == header.first_row\nd = row is None\ne = row == 'x'\n"
+ root = _tree(tmp_path, {"detect.py": code})
+
+ assert find_violations(root) == []
+
+
+@pytest.mark.parametrize("subscript", ["cells[(3, 4)]", "cells[3, 4]", "sheet.cells[(1, 1)]"])
+def test_constant_cell_subscript_is_caught(tmp_path: Path, subscript: str) -> None:
+ root = _tree(tmp_path, {"detect.py": f"value = {subscript}\n"})
+
+ (violation,) = find_violations(root)
+
+ assert violation.line == 1
+
+
+def test_subscript_with_variable_indexes_is_allowed(tmp_path: Path) -> None:
+ root = _tree(tmp_path, {"detect.py": "value = cells[(row, col)]\nother = pair[0]\nthird = cells[(r, 1)]\n"})
+
+ assert find_violations(root) == []
+
+
+def test_literal_in_annotations_is_allowed(tmp_path: Path) -> None:
+ code = (
+ "from typing import Literal\n"
+ "Alias = Literal['Q9', 'ZZ']\n"
+ "def f(x: Literal['Q1', 'Q2'], *rest: Literal['AB']) -> Literal['CD']:\n"
+ " y: Literal['EF'] = 'EF'\n"
+ " return y\n"
+ )
+ root = _tree(tmp_path, {"detect.py": code})
+
+ violations = find_violations(root)
+
+ # В аннотациях и Literal — можно; присваиваемое значение 'EF' вне аннотации — нельзя
+ assert [violation.line for violation in violations] == [4]
+
+
+def test_contract_enum_value_is_allowed_but_foreign_short_literal_is_not(tmp_path: Path) -> None:
+ root = _tree(tmp_path, {"detect.py": 'kind = "BU"\nquarter = "Q3"\nstray = "ZQ"\n'})
+
+ violations = find_violations(root)
+
+ assert [violation.line for violation in violations] == [3]
+
+
+def test_grid_py_and_readers_are_excluded_but_other_files_are_not(tmp_path: Path) -> None:
+ root = _tree(
+ tmp_path,
+ {
+ "grid.py": 'x = "A"\nif row == 1:\n pass\n',
+ "readers/xlsx.py": 'x = "E9"\ny = cells[(1, 1)]\n',
+ "nested/grid.py": 'x = "A"\n',
+ "classify.py": 'x = "A"\n',
+ },
+ )
+
+ violations = find_violations(root)
+
+ assert sorted(violation.path.relative_to(root).as_posix() for violation in violations) == [
+ "classify.py",
+ "nested/grid.py",
+ ]
+
+
+def test_int_literal_type_is_not_a_cell_subscript(tmp_path: Path) -> None:
+ root = _tree(tmp_path, {"detect.py": "from typing import Literal\nVariant = Literal[1, 2]\n"})
+
+ assert find_violations(root) == []
+
+
+@pytest.mark.parametrize(
+ "call",
+ [
+ "sheet.cell(3, 4)",
+ "cell(3, 4)",
+ "self.sheet.text_at(3, 4)",
+ "sheet.cell(row=3, col=4)",
+ "sheet.cell(3, 4, extra)",
+ ],
+)
+def test_cell_call_with_constant_row_and_column_is_caught(tmp_path: Path, call: str) -> None:
+ root = _tree(tmp_path, {"detect.py": f"value = {call}\n"})
+
+ (violation,) = find_violations(root)
+
+ assert violation.line == 1
+ assert "обращение к ячейке" in violation.message
+
+
+@pytest.mark.parametrize(
+ "call",
+ [
+ "sheet.cell(row, col)",
+ "sheet.cell(3, col)",
+ "sheet.cell(row, 4)",
+ "sheet.text_at(item.row, 2)",
+ "sheet.cells(3, 4)",
+ "other(3, 4)",
+ ],
+)
+def test_cell_call_with_variable_or_foreign_arguments_is_allowed(tmp_path: Path, call: str) -> None:
+ root = _tree(tmp_path, {"detect.py": f"value = {call}\n"})
+
+ assert find_violations(root) == []
+
+
+def test_planted_file_with_every_kind_of_coordinate_is_caught_line_by_line(tmp_path: Path) -> None:
+ """Намеренно подложенный файл: адрес «E9», буква «A», имя листа, `row == 5`, `cells[(3, 4)]`, `cell(3, 4)`."""
+ code = (
+ 'address = "E9"\n'
+ 'letter = "A"\n'
+ 'name = "Лист1"\n'
+ "header = row == 5\n"
+ "value = cells[(3, 4)]\n"
+ "other = sheet.cell(3, 4)\n"
+ )
+ root = _tree(tmp_path, {"classify.py": code})
+
+ assert [violation.line for violation in find_violations(root)] == [1, 2, 3, 4, 5, 6]
diff --git a/api/tests/unit/parser/test_numbers.py b/api/tests/unit/parser/test_numbers.py
new file mode 100644
index 0000000..0440c7f
--- /dev/null
+++ b/api/tests/unit/parser/test_numbers.py
@@ -0,0 +1,239 @@
+"""Числа: суммы -> Decimal(0.01), текстовые форматы, пусто -> None; ИНН (design D-5, Review Focus 2)."""
+
+import datetime
+from decimal import Decimal
+
+import pytest
+
+from src.parser import detect
+from src.parser.grid import EMPTY_CELL, Cell
+from src.parser.numbers import MONEY_Q, MoneyParse, inn_digits, to_money
+
+MAX_WARNING_LENGTH = 100
+
+
+def num(value: object) -> Cell:
+ """Числовая ячейка так, как её кладут читатели: Decimal(str(value))."""
+ return Cell(Decimal(str(value)), "number")
+
+
+def text(value: str) -> Cell:
+ return Cell(value, "text")
+
+
+def assert_money(parsed: MoneyParse, expected: str) -> None:
+ assert parsed.warning is None
+ assert parsed.value is not None
+ assert str(parsed.value) == expected # сравнение строкой: точный вид (две цифры, без «-0.00», без хвостов)
+
+
+# --- Числовые ячейки ---
+
+
+@pytest.mark.parametrize(
+ ("cell", "expected"),
+ [
+ (num(0.1 + 0.2), "0.30"), # float-хвост 0.30000000000000004 не попадает в результат
+ (num(5), "5.00"),
+ (num(1234567.891), "1234567.89"),
+ (num(2.675), "2.68"), # ROUND_HALF_UP по строковому представлению, а не по двоичному float
+ (num(-2.675), "-2.68"),
+ (num(1.005), "1.01"),
+ (num(0.1 * 3), "0.30"),
+ (num(1e-12), "0.00"), # шум вычитания в Excel
+ (num(123456789012345.67), "123456789012345.67"),
+ (Cell(Decimal("100"), "number"), "100.00"),
+ (Cell(Decimal("1E+3"), "number"), "1000.00"),
+ ],
+)
+def test_number_cells_are_quantized_half_up(cell: Cell, expected: str) -> None:
+ assert_money(to_money(cell), expected)
+
+
+def test_money_quantum_is_one_kopeck() -> None:
+ assert MONEY_Q == Decimal("0.01")
+
+
+def test_result_is_decimal_never_float() -> None:
+ parsed = to_money(num(0.1 + 0.2))
+
+ assert isinstance(parsed.value, Decimal)
+
+
+@pytest.mark.parametrize(
+ "cell",
+ [num(0), num(-0.0), num("-0"), num(-0.001), text("-0"), text("(0,00)"), text("-0,00"), text("−0")],
+)
+def test_negative_zero_is_normalized(cell: Cell) -> None:
+ assert_money(to_money(cell), "0.00")
+
+
+def test_bool_is_none_with_warning() -> None:
+ parsed = to_money(Cell(True, "bool"))
+
+ assert parsed.value is None
+ assert parsed.warning
+
+
+def test_date_is_none_with_warning() -> None:
+ parsed = to_money(Cell(datetime.date(2025, 1, 31), "date"))
+
+ assert parsed.value is None
+ assert parsed.warning
+
+
+def test_empty_cell_is_none_without_warning() -> None:
+ assert to_money(EMPTY_CELL) == MoneyParse(None, None)
+
+
+@pytest.mark.parametrize("raw", ["NaN", "Infinity", "-Infinity", "sNaN"])
+def test_non_finite_number_is_none_with_warning(raw: str) -> None:
+ parsed = to_money(Cell(Decimal(raw), "number"))
+
+ assert parsed.value is None
+ assert parsed.warning
+
+
+@pytest.mark.parametrize("raw", ["1E+16", "-1E+16", "1E+300", "1E+999999", "-12345678901234567.89"])
+def test_too_large_number_is_none_with_warning(raw: str) -> None:
+ parsed = to_money(Cell(Decimal(raw), "number"))
+
+ assert parsed.value is None
+ assert parsed.warning
+
+
+def test_largest_money_value_is_accepted() -> None:
+ assert_money(to_money(num("9999999999999999.99")), "9999999999999999.99")
+
+
+def test_extremely_small_number_is_none_with_warning() -> None:
+ parsed = to_money(Cell(Decimal("1E-999999"), "number"))
+
+ assert parsed.value is None
+ assert parsed.warning
+
+
+# --- Текст ---
+
+
+@pytest.mark.parametrize(
+ ("raw", "expected"),
+ [
+ ("1 234 567,89", "1234567.89"),
+ ("(1 234,00)", "-1234.00"),
+ ("1 234 567,89", "1234567.89"), # неразрывный пробел
+ ("1 234,5", "1234.50"), # узкий неразрывный
+ ("1 234", "1234.00"), # тонкий
+ ("1\t234", "1234.00"),
+ (" 12,5 ", "12.50"),
+ ("1234.567", "1234.57"),
+ ("−1 234,56", "-1234.56"), # U+2212
+ ("-1234,56", "-1234.56"),
+ ("(1234)", "-1234.00"),
+ ("8.577e+00", "8.58"),
+ ("8,577E+02", "857.70"),
+ ("1.5e3", "1500.00"),
+ ("2.675", "2.68"),
+ ("0", "0.00"),
+ ("1e-30", "0.00"),
+ ],
+)
+def test_text_formats(raw: str, expected: str) -> None:
+ assert_money(to_money(text(raw)), expected)
+
+
+@pytest.mark.parametrize("raw", ["-", "—", "–", "", " ", " ", " - ", "−"])
+def test_dashes_and_blank_text_are_none_without_warning(raw: str) -> None:
+ assert to_money(text(raw)) == MoneyParse(None, None)
+
+
+@pytest.mark.parametrize(
+ "raw",
+ ["abc", "12abc", "1.234,56", "1,2,3", "--5", "(-5)", "(1 234", "1 234)", "nan", "inf", "-inf", "Infinity", "NaN",
+ "1e", "e5", "0x10", "٣٤٥", "₽ 100", "100 руб.", "1e999999999999999999999", "1e99999", "1e400"],
+) # fmt: skip
+def test_non_numeric_text_is_none_with_warning(raw: str) -> None:
+ parsed = to_money(text(raw))
+
+ assert parsed.value is None
+ assert parsed.warning
+
+
+def test_warning_is_russian_short_and_has_no_coordinates() -> None:
+ parsed = to_money(text("abc"))
+
+ assert parsed.warning is not None
+ assert "abc" in parsed.warning
+ assert any("а" <= ch.lower() <= "я" for ch in parsed.warning)
+ assert len(parsed.warning) < MAX_WARNING_LENGTH
+
+
+def test_long_garbage_is_truncated_in_warning() -> None:
+ parsed = to_money(text("x" * 500))
+
+ assert parsed.warning is not None
+ assert len(parsed.warning) < MAX_WARNING_LENGTH
+
+
+def test_too_large_text_is_none_with_warning() -> None:
+ parsed = to_money(text("1" * 40))
+
+ assert parsed.value is None
+ assert parsed.warning
+
+
+# --- ИНН ---
+
+
+@pytest.mark.parametrize(
+ ("cell", "expected"),
+ [
+ (text("3528052718"), "3528052718"),
+ (num("3528052718.0"), "3528052718"),
+ (text("352819549855"), "352819549855"),
+ (text(" 352819549855 "), "352819549855"),
+ (num("352819549855"), "352819549855"),
+ (num("123456789"), "0123456789"), # потерян ведущий ноль: восстанавливает извлечение (R-24)
+ (text("123"), None),
+ (num("123"), None),
+ (text("ИНН 352819549855"), "352819549855"), # R-15: цифры из обрамляющего текста
+ (text("ИНН: 3528052718"), "3528052718"),
+ (text("3528 052718"), "3528052718"),
+ (text("123 456"), None),
+ (text("ИНН 35280527181"), None), # 11 цифр: ведущий ноль к извлечённым цифрам не добавляется
+ (text("ИНН 352805271"), None), # 9 цифр
+ (text("№ 12/2025"), None),
+ (text("№ 12 от 01.02.2025"), None), # T7.4: 2+2+2+4 = 10 цифр из разных групп — не ИНН
+ (text("№ 12 / 3528052718 / …"), None), # T7.4: ИНН рядом с другими числами не склеивается
+ (text("ИНН/КПП 3528052718/352801001"), None),
+ (text("3528\u00a0052718"), "3528052718"), # группы через неразрывный пробел
+ (text("3528\u202f052718"), "3528052718"), # и через узкий неразрывный
+ (text("ИНН 3528 052718 (Вологда)"), "3528052718"),
+ (text("ИНН"), None),
+ (num("3528052718.5"), None),
+ (num("-3528052718"), None),
+ (EMPTY_CELL, None),
+ (Cell(True, "bool"), None),
+ ],
+)
+def test_inn_digits(cell: Cell, expected: str | None) -> None:
+ assert inn_digits(cell) == expected
+
+
+def test_inn_digits_is_superset_of_strict_detector_rule() -> None:
+ cells = (text("3528052718"), text(" 352819549855 "), num("3528052718.0"), num("352819549855"))
+ for cell in cells:
+ assert detect.inn_from_cell(cell) is not None
+ assert inn_digits(cell) == detect.inn_from_cell(cell)
+
+
+def test_only_extraction_restores_lost_leading_zero() -> None:
+ """R-24: ведущий ноль числа из 9/11 цифр восстанавливает извлечение из уже выбранной колонки ИНН, а не правило
+ детектора, которым колонка выбирается по значениям (иначе числовой КПП сошёл бы за ИНН)."""
+ for cell, padded in ((num("123456789"), "0123456789"), (num("12345678901"), "012345678901")):
+ assert detect.inn_from_cell(cell) is None
+ assert inn_digits(cell) == padded
+
+
+def test_detector_rule_stays_strict_for_prefixed_text() -> None:
+ assert detect.inn_from_cell(text("ИНН 352819549855")) is None
diff --git a/api/tests/unit/parser/test_osv_parser.py b/api/tests/unit/parser/test_osv_parser.py
new file mode 100644
index 0000000..b3cf5e8
--- /dev/null
+++ b/api/tests/unit/parser/test_osv_parser.py
@@ -0,0 +1,517 @@
+"""Оркестратор `OsvParser` (design D-6, D-8; spec «Листы ОСВ находятся по содержимому», «Контрольные суммы определяют
+статус», «Форматы, лимиты, время», «Словари — данные, не код»).
+
+Книги собираются openpyxl в памяти: лист описывается строками сверху вниз, как в `gridbuilder` (`M` — объединённая
+ячейка, `L` — outline level строки первым элементом). Позиции колонок произвольные — парсер находит их по тексту.
+"""
+
+import io
+import itertools
+import shutil
+import time
+import traceback
+from collections.abc import Callable, Iterator, Sequence
+from decimal import Decimal
+from pathlib import Path
+from typing import Any
+
+import openpyxl
+import pytest
+from gridbuilder import L, M, sheet_from_rows
+from loguru import logger
+from result_contract import assert_valid_result
+
+from src.contract import ErrorCode, OsvDocument, ResultStatus
+from src.parser import ParseOutcome, hierarchy
+from src.parser.classify import SUM_FIELDS
+from src.parser.deadline import Deadline
+from src.parser.errors import ParseTimeoutError
+from src.parser.hierarchy import BodyRow
+from src.parser.osv import OsvParser
+from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE, Rules, load_rules
+
+OPENING = "Сальдо на начало периода"
+TURNOVER = "Обороты за период"
+CLOSING = "Сальдо на конец периода"
+TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
+MAX_BYTES = 50 * 1024 * 1024
+TIMEOUT_SECONDS = 120
+# Строки мини-ОСВ: шапка 6 строк, блок заголовка 2 строки → данные с 9-й
+ACCOUNT_ROW, SUPPLIER_ROW, CONTRACTOR_ROW, TOTAL_ROW = 9, 10, 11, 12
+DATA_ROWS = 3
+# Часы-заглушка: каждый вызов +100 с; лимит 150 с истекает на второй проверке дедлайна
+CLOCK_STEP = 100
+SHORT_TIMEOUT = 150
+
+Cells = list[object]
+
+
+@pytest.fixture(scope="module")
+def rules() -> Rules:
+ return load_rules()
+
+
+def xlsx(sheets: dict[str, Sequence[Cells]]) -> bytes:
+ """Книга XLSX: имя листа → строки (значение, `M` — merged-диапазон, `L` — outline level первым элементом)."""
+ book = openpyxl.Workbook()
+ default = book.active
+ assert default is not None
+ book.remove(default)
+ for name, rows in sheets.items():
+ sheet = book.create_sheet(name)
+ for row_index, cells in enumerate(rows, start=1):
+ col = 1
+ for value in cells:
+ if isinstance(value, L):
+ sheet.row_dimensions[row_index].outline_level = value.level
+ continue
+ if isinstance(value, M):
+ sheet.cell(row_index, col, value.value)
+ sheet.merge_cells(
+ start_row=row_index,
+ start_column=col,
+ end_row=row_index + value.height - 1,
+ end_column=col + value.width - 1,
+ )
+ col += value.width
+ continue
+ if value is not None:
+ sheet.cell(row_index, col, value)
+ col += 1
+ buffer = io.BytesIO()
+ book.save(buffer)
+ return buffer.getvalue()
+
+
+def head(opening: str = OPENING, title: str = TITLE) -> list[Cells]:
+ """Шапка варианта 1 и блок заголовка «Счет | начало | обороты | конец», группировки Счет / Контрагенты (с ИНН)."""
+ return [
+ ['АО "Ромашка"'], # 1
+ [title], # 2
+ [], # 3
+ ["Выводимые данные: БУ (данные бухгалтерского учета)"], # 4
+ ["Параметры:", None, "Валюта БУ, НУ: RUB"], # 5
+ [], # 6
+ ["Счет", None, M(opening, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 7
+ ["Контрагенты", "ИНН", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], # 8
+ ]
+
+
+def data(level: int, text: str, sums: Sequence[object], inn: object = None) -> Cells:
+ return [L(level), text, inn, *sums]
+
+
+ROOT = (100, None, 50, 30, 120, None)
+SUPPLIER = (60, None, 20, 10, 70, None)
+CONTRACTOR = (40, None, 30, 20, 50, None)
+
+
+def mini_osv(supplier: Sequence[object] = SUPPLIER, *, total: bool = True, opening: str = OPENING) -> list[Cells]:
+ rows = [
+ *head(opening),
+ data(0, "60", ROOT), # 9
+ data(1, "ООО Поставщик", supplier, inn="7701234567"), # 10
+ data(1, "ООО Подрядчик", CONTRACTOR, inn="7707654321"), # 11
+ ]
+ if total:
+ rows.append(data(0, "Итого", ROOT)) # 12
+ return rows
+
+
+def statement() -> list[Cells]:
+ return [["Бухгалтерский баланс"], ["на 31 марта 2025 г."], ["Актив", "Код", "На 31 марта 2025 г."]]
+
+
+def parser(
+ rules: Rules,
+ *,
+ max_bytes: int = MAX_BYTES,
+ timeout_seconds: float = TIMEOUT_SECONDS,
+ clock: Callable[[], float] = time.monotonic,
+) -> OsvParser:
+ return OsvParser(rules, max_bytes=max_bytes, timeout_seconds=timeout_seconds, clock=clock)
+
+
+def only_document(outcome: ParseOutcome) -> OsvDocument:
+ (document,) = outcome.documents
+ assert isinstance(document, OsvDocument)
+ return document
+
+
+def warnings_of(outcome: ParseOutcome) -> list[str]:
+ assert outcome.metrics is not None
+ assert outcome.metrics.warnings is not None
+ return outcome.metrics.warnings
+
+
+# --- Штатный разбор ---
+
+
+def test_mini_osv_is_done_with_full_fields(rules: Rules) -> None:
+ outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
+
+ assert outcome.status is ResultStatus.DONE
+ assert outcome.error is None
+ document = only_document(outcome)
+ assert document.document_type == "OSV"
+ assert document.locator == "sheet:ОСВ 60"
+ header = document.header
+ assert (header.organization_name, header.account_code_raw, header.period_text) == (
+ 'АО "Ромашка"',
+ "60",
+ "1 квартал 2025 г.",
+ )
+ assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, "Q1", True)
+ assert (header.data_type, header.currency, header.header_variant) == ("BU", "RUB", 1)
+ assert [(group.type, group.name, group.has_inn) for group in document.group_schema] == [
+ ("ACCOUNT", "Счет", None),
+ ("COUNTERPARTY", "Контрагенты", True),
+ ]
+ assert document.columns == {
+ "opening_debit": "C",
+ "opening_credit": "D",
+ "turnover_debit": "E",
+ "turnover_credit": "F",
+ "closing_debit": "G",
+ "closing_credit": "H",
+ "inn": "B",
+ }
+ rows = document.rows
+ assert [(row.row_number, row.level, row.parent_row_number, row.group_type) for row in rows] == [
+ (ACCOUNT_ROW, 0, None, "ACCOUNT"),
+ (SUPPLIER_ROW, 1, ACCOUNT_ROW, "COUNTERPARTY"),
+ (CONTRACTOR_ROW, 1, ACCOUNT_ROW, "COUNTERPARTY"),
+ (TOTAL_ROW, 0, None, "TOTAL"),
+ ]
+ supplier = rows[1]
+ assert (supplier.row_value, supplier.counterparty_name, supplier.counterparty_inn) == (
+ "ООО Поставщик",
+ "ООО Поставщик",
+ "7701234567",
+ )
+ assert (supplier.group_name, supplier.account_code_raw, supplier.data_type) == ("Контрагенты", "60", "BU")
+ sums = [getattr(supplier, name) for name in SUM_FIELDS]
+ assert sums == [Decimal(60), None, Decimal(20), Decimal(10), Decimal(70), None]
+ assert document.checks is not None
+ assert document.checks.total_row_matches_root is True
+ assert document.checks.balance_identity_violations == []
+ assert document.checks.parent_sum_violations == []
+ assert outcome.metrics is not None
+ assert outcome.metrics.rows == DATA_ROWS
+ assert outcome.metrics.duration_ms is not None
+ assert outcome.metrics.duration_ms >= 0
+ assert warnings_of(outcome) == ["sheet=ОСВ 60 hierarchy=outline"]
+ payload = assert_valid_result(outcome)
+ assert payload["schema_version"] == "1.1"
+
+
+def test_spoiled_sum_needs_check_with_row_numbers(rules: Rules) -> None:
+ """Spec «Испорченная сумма»: `closing_debit` контрагента +1 → тождество в его строке и сумма родителя."""
+ spoiled = (60, None, 20, 10, 71, None)
+
+ outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv(spoiled)}), "XLSX")
+
+ assert outcome.status is ResultStatus.NEEDS_CHECK
+ checks = only_document(outcome).checks
+ assert checks is not None
+ assert checks.balance_identity_violations == [SUPPLIER_ROW]
+ assert checks.parent_sum_violations == [ACCOUNT_ROW]
+ assert checks.total_row_matches_root is True
+ warnings = warnings_of(outcome)
+ assert warnings[0] == "sheet=ОСВ 60 hierarchy=outline"
+ assert any(f"строки {SUPPLIER_ROW}" in warning for warning in warnings), warnings
+ assert any(f"строки {ACCOUNT_ROW}" in warning for warning in warnings), warnings
+ assert_valid_result(outcome)
+
+
+def test_missing_total_needs_check(rules: Rules) -> None:
+ """R-20: нет «Итого» — сверку с корнем выполнить нельзя: `false`, предупреждение, `needs_check`."""
+ outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv(total=False)}), "XLSX")
+
+ assert outcome.status is ResultStatus.NEEDS_CHECK
+ checks = only_document(outcome).checks
+ assert checks is not None
+ assert checks.total_row_matches_root is False
+ assert "строка «Итого» не найдена" in warnings_of(outcome)
+
+
+def test_empty_osv_is_done_with_total_only(rules: Rules) -> None:
+ """Spec «Пустая ОСВ» (R-13): заголовок и сразу «Итого» без сумм."""
+ outcome = parser(rules).parse(xlsx({"ОСВ 66": [*head(), [None, "Итого"]]}), "XLSX")
+
+ assert outcome.status is ResultStatus.DONE
+ document = only_document(outcome)
+ assert [(row.group_type, row.level, row.parent_row_number) for row in document.rows] == [("TOTAL", 0, None)]
+ assert document.checks is not None
+ assert document.checks.total_row_matches_root is True
+ assert outcome.metrics is not None
+ assert outcome.metrics.rows == 0
+ assert warnings_of(outcome) == ["sheet=ОСВ 66 hierarchy=trivial", "нет строк данных"]
+ assert_valid_result(outcome)
+
+
+def test_number_warnings_carry_row_numbers(rules: Rules) -> None:
+ rows = mini_osv()
+ rows[CONTRACTOR_ROW - 1] = data(1, "ООО Подрядчик", (40, None, 30, 20, 50, "н/д"), inn="7707654321")
+
+ outcome = parser(rules).parse(xlsx({"ОСВ 60": rows}), "XLSX")
+
+ assert outcome.status is ResultStatus.DONE
+ assert any(warning.startswith(f"строка {CONTRACTOR_ROW}:") for warning in warnings_of(outcome))
+
+
+# --- Несколько листов ---
+
+
+def test_statement_sheet_is_skipped_and_osv_parsed(rules: Rules) -> None:
+ outcome = parser(rules).parse(xlsx({"Баланс": statement(), "ОСВ 60": mini_osv()}), "XLSX")
+
+ assert outcome.status is ResultStatus.DONE
+ assert only_document(outcome).locator == "sheet:ОСВ 60"
+ assert warnings_of(outcome) == ["sheet=Баланс skipped: statement (slice 2b)", "sheet=ОСВ 60 hierarchy=outline"]
+ assert_valid_result(outcome)
+
+
+def test_two_osv_sheets_and_other_sheet(rules: Rules) -> None:
+ """Spec «Два листа ОСВ и посторонний лист»: два документа в порядке листов, пропуск «Справки» в warnings."""
+ other: list[Cells] = [["Справка о дебиторской задолженности"], ["Контрагент", "Сумма"], ["ООО Лютик", 5]]
+ book = xlsx({"ОСВ 60": mini_osv(), "Справка": other, "ОСВ 62": mini_osv()})
+
+ outcome = parser(rules).parse(book, "XLSX")
+
+ assert outcome.status is ResultStatus.DONE
+ assert [document.locator for document in outcome.documents] == ["sheet:ОСВ 60", "sheet:ОСВ 62"]
+ assert outcome.metrics is not None
+ assert outcome.metrics.rows == 2 * DATA_ROWS
+ warnings = warnings_of(outcome)
+ assert "sheet=ОСВ 60 hierarchy=outline" in warnings
+ assert "sheet=ОСВ 62 hierarchy=outline" in warnings
+ assert any(warning.startswith("sheet=Справка skipped") for warning in warnings), warnings
+ assert_valid_result(outcome)
+
+
+def test_one_spoiled_sheet_makes_whole_result_needs_check(rules: Rules) -> None:
+ spoiled = (60, None, 20, 10, 71, None)
+ book = xlsx({"ОСВ 60": mini_osv(), "ОСВ 62": mini_osv(spoiled)})
+
+ outcome = parser(rules).parse(book, "XLSX")
+
+ assert outcome.status is ResultStatus.NEEDS_CHECK
+ assert len(outcome.documents) == 2 # noqa: PLR2004 - два листа ОСВ
+
+
+# --- Ошибки (карта D-6) ---
+
+
+def test_book_without_osv_is_invalid_document(rules: Rules) -> None:
+ """Spec «Нет ОСВ»: сообщение называет число просмотренных листов."""
+ outcome = parser(rules).parse(xlsx({"Баланс": statement(), "Данные": [["a", "b"], [1, 2]]}), "XLSX")
+
+ assert outcome.status is ResultStatus.ERROR
+ assert outcome.error is not None
+ assert outcome.error.code is ErrorCode.INVALID_DOCUMENT
+ assert "листов просмотрено 2" in outcome.error.message
+ assert outcome.documents == ()
+
+
+def test_osv_sheet_without_table_header_is_invalid_format(rules: Rules) -> None:
+ rows: list[Cells] = [['АО "Ромашка"'], [TITLE], [], ["Счет", "Сумма"], ["60", 100]]
+
+ outcome = parser(rules).parse(xlsx({"ОСВ 60": rows}), "XLSX")
+
+ assert outcome.status is ResultStatus.ERROR
+ assert outcome.error is not None
+ assert outcome.error.code is ErrorCode.INVALID_FORMAT
+ assert "«ОСВ 60»" in outcome.error.message
+ assert "заголовок таблицы" in outcome.error.message
+ assert outcome.error.message.count("ОСВ 60") == 1
+
+
+def test_undetermined_hierarchy_is_invalid_format_with_sheet_and_step(
+ rules: Rules, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ def nothing(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> None:
+ return None
+
+ monkeypatch.setattr(hierarchy, "STRATEGIES", (("nothing", nothing),))
+
+ outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
+
+ assert outcome.error is not None
+ assert outcome.error.code is ErrorCode.INVALID_FORMAT
+ assert "«ОСВ 60»" in outcome.error.message
+ assert "иерархия" in outcome.error.message
+
+
+def test_size_over_limit_is_unsupported_format_before_reading(rules: Rules) -> None:
+ """Spec «Превышен лимит»: на байт больше лимита — `UNSUPPORTED_FORMAT`, ровно лимит — разбор."""
+ book = xlsx({"ОСВ 60": mini_osv()})
+
+ over = parser(rules, max_bytes=len(book) - 1).parse(book, "XLSX")
+ exact = parser(rules, max_bytes=len(book)).parse(book, "XLSX")
+
+ assert over.status is ResultStatus.ERROR
+ assert over.error is not None
+ assert over.error.code is ErrorCode.UNSUPPORTED_FORMAT
+ assert "лимит" in over.error.message
+ assert exact.status is ResultStatus.DONE
+
+
+def test_tiny_limit_rejects_any_file(rules: Rules) -> None:
+ outcome = parser(rules, max_bytes=10).parse(b"PK\x03\x04" + b"x" * 7, "XLSX")
+
+ assert outcome.error is not None
+ assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
+
+
+def test_unknown_signature_is_unsupported_format(rules: Rules) -> None:
+ outcome = parser(rules).parse(b"%PDF-1.7 not a workbook", "XLSX")
+
+ assert outcome.error is not None
+ assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
+
+
+def test_broken_zip_is_unreadable(rules: Rules) -> None:
+ outcome = parser(rules).parse(b"PK\x03\x04 broken archive", "XLSX")
+
+ assert outcome.error is not None
+ assert outcome.error.code is ErrorCode.UNREADABLE
+
+
+def test_deadline_exceeded_is_timeout(rules: Rules) -> None:
+ clock = itertools.count(step=CLOCK_STEP)
+
+ outcome = parser(rules, timeout_seconds=SHORT_TIMEOUT, clock=lambda: float(next(clock))).parse(
+ xlsx({"ОСВ 60": mini_osv()}), "XLSX"
+ )
+
+ assert outcome.status is ResultStatus.ERROR
+ assert outcome.error is not None
+ assert outcome.error.code is ErrorCode.TIMEOUT
+ assert "превышен лимит" in outcome.error.message
+
+
+def test_unexpected_exception_is_recognition_failed_in_one_line(rules: Rules, monkeypatch: pytest.MonkeyPatch) -> None:
+ def broken(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
+ raise ValueError("сбой стратегии\nвторая строка")
+
+ monkeypatch.setattr(hierarchy, "STRATEGIES", (("broken", broken),))
+
+ outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
+
+ assert outcome.status is ResultStatus.ERROR
+ assert outcome.error is not None
+ assert outcome.error.code is ErrorCode.RECOGNITION_FAILED
+ assert outcome.error.message == "ValueError: сбой стратегии вторая строка"
+
+
+@pytest.fixture
+def log_records() -> Iterator[list[Any]]:
+ """Записи loguru, выпущенные за время теста."""
+ records: list[Any] = []
+ handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG")
+ yield records
+ logger.remove(handler_id)
+
+
+def test_unexpected_exception_is_logged_with_traceback(
+ rules: Rules, monkeypatch: pytest.MonkeyPatch, log_records: list[Any]
+) -> None:
+ """I-2: ответ прежний (RECOGNITION_FAILED одной строкой), но стек непредвиденной ошибки уходит в лог."""
+
+ def broken(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
+ raise ValueError("сбой стратегии\nвторая строка")
+
+ monkeypatch.setattr(hierarchy, "STRATEGIES", (("broken", broken),))
+
+ outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
+
+ assert outcome.error is not None
+ assert outcome.error.code is ErrorCode.RECOGNITION_FAILED
+ assert outcome.error.message == "ValueError: сбой стратегии вторая строка"
+ (record,) = [record for record in log_records if record["level"].name == "ERROR"]
+ assert "Непредвиденная ошибка разбора" in record["message"]
+ assert "ValueError: сбой стратегии вторая строка" in record["message"]
+ exception = record["exception"]
+ assert exception is not None
+ assert exception.type is ValueError
+ assert exception.traceback is not None
+ assert "broken" in "".join(traceback.format_tb(exception.traceback))
+
+
+def test_unreadable_book_is_logged_as_warning_without_traceback(rules: Rules, log_records: list[Any]) -> None:
+ outcome = parser(rules).parse(b"PK\x03\x04 broken archive", "XLSX")
+
+ assert outcome.error is not None
+ assert outcome.error.code is ErrorCode.UNREADABLE
+ (record,) = [record for record in log_records if record["level"].name == "WARNING"]
+ assert outcome.error.message in record["message"]
+ assert record["exception"] is None
+ assert not [record for record in log_records if record["level"].name == "ERROR"]
+
+
+def test_expected_errors_other_than_unreadable_are_not_logged(rules: Rules, log_records: list[Any]) -> None:
+ outcome = parser(rules).parse(b"%PDF-1.7", "XLSX")
+
+ assert outcome.error is not None
+ assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
+ assert not [record for record in log_records if record["level"].no >= logger.level("WARNING").no]
+
+
+def test_header_search_is_interrupted_by_deadline(rules: Rules) -> None:
+ """R-23 (в): дедлайн из `parse_sheet` доходит до поиска заголовка таблицы — цикла по всему листу."""
+ sheet = sheet_from_rows([[TITLE], *([["строка"]] * 1000)])
+ deadline = Deadline(1, clock=itertools.chain([0.0], itertools.repeat(1e9)).__next__)
+
+ with pytest.raises(ParseTimeoutError, match="поиск заголовка таблицы"):
+ parser(rules).parse_sheet(sheet, deadline)
+
+
+# --- Словари — данные (R-8, spec «Новый синоним без релиза кода») ---
+
+NEW_OPENING = "Нач. остаток"
+NEW_SYNONYM_LINE = " - 'нач\\.\\s*остаток'\n"
+
+
+def rules_with_new_opening_synonym(tmp_path: Path) -> Path:
+ """Копия штатного каталога словарей, в `osv_headers.yaml` синоним «Нач. остаток» добавлен к группе opening."""
+ copy = tmp_path / "rules"
+ shutil.copytree(DEFAULT_RULES_DIR, copy)
+ headers = copy / HEADERS_FILE
+ text = headers.read_text(encoding="utf-8")
+ anchor = " opening:\n"
+ assert text.count(anchor) == 1
+ headers.write_text(text.replace(anchor, anchor + NEW_SYNONYM_LINE), encoding="utf-8")
+ return copy
+
+
+def test_new_synonym_parses_without_code_changes(tmp_path: Path, rules: Rules) -> None:
+ book = xlsx({"ОСВ 60": mini_osv(opening=NEW_OPENING)})
+
+ outcome = parser(load_rules(rules_with_new_opening_synonym(tmp_path))).parse(book, "XLSX")
+
+ assert outcome.status is ResultStatus.DONE
+ document = only_document(outcome)
+ assert document.columns is not None
+ assert (document.columns["opening_debit"], document.columns["opening_credit"]) == ("C", "D")
+ assert document.rows[0].opening_debit == Decimal(100)
+ assert_valid_result(outcome)
+
+
+def test_default_dictionary_does_not_know_the_new_synonym(rules: Rules) -> None:
+ """До правки словаря «Нач. остаток» не распознаётся: заголовок находится по «оборотам» и «концу» (начало
+ необязательно, R-12), колонки начала уходят в `extra`, `opening_*` = null, и тождество сальдо не сходится в
+ строках с ненулевым началом → `needs_check` (фактическое поведение, зафиксировано тестом).
+ """
+ outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv(opening=NEW_OPENING)}), "XLSX")
+
+ assert outcome.status is ResultStatus.NEEDS_CHECK
+ document = only_document(outcome)
+ assert document.columns is not None
+ assert "opening_debit" not in document.columns
+ assert all(row.opening_debit is None and row.opening_credit is None for row in document.rows)
+ assert document.rows[0].extra is not None
+ assert "100" in document.rows[0].extra.values()
+ checks = document.checks
+ assert checks is not None
+ assert checks.balance_identity_violations == [ACCOUNT_ROW, SUPPLIER_ROW, CONTRACTOR_ROW, TOTAL_ROW]
diff --git a/api/tests/unit/parser/test_read_limits.py b/api/tests/unit/parser/test_read_limits.py
new file mode 100644
index 0000000..412b783
--- /dev/null
+++ b/api/tests/unit/parser/test_read_limits.py
@@ -0,0 +1,560 @@
+"""Лимиты фазы чтения (ruling R-23, находка I-1 финального ревью): распакованный размер zip-архива и суммарная
+площадь объединённых ячеек проверяются до библиотеки чтения — бомба из нескольких килобайт не доходит до openpyxl.
+
+Книги собираются в памяти: в fixtures ничего не пишется.
+"""
+
+import io
+import time
+import zipfile
+from collections.abc import Callable, Iterator
+from typing import Any
+
+import openpyxl
+import pytest
+
+from src.contract import ErrorCode, ResultStatus
+from src.parser.errors import UnreadableError, UnsupportedFormatError
+from src.parser.grid import MAX_INDEXED_AREA, MergedRange, Sheet
+from src.parser.osv import OsvParser
+from src.parser.readers import _READERS, read_book
+from src.parser.readers._limits import (
+ LARGE_MERGED_COUNT_LIMIT,
+ MERGED_AREA_LIMIT,
+ ZIP_UNCOMPRESSED_LIMIT_FACTOR,
+ check_merged_area,
+ check_xlsx_merged_area,
+ merged_area_of_xml,
+)
+from src.parser.rules import Rules, load_rules
+
+FAST_SECONDS = 1.0
+SHEET_PART = "xl/worksheets/sheet1.xml"
+ZEROS_BYTES = 10 * 1024 * 1024 # «десятки МБ» нулей сжимаются в ~10 КБ
+BOMB_MAX_FILE_BYTES = 10_000 # бомба — несколько килобайт файла
+SMALL_MAX_BYTES = 100_000 # лимит размера файла в тесте: распакованный лимит = 10 × 100 000 байт
+MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
+_PACKAGE_RELS_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
+_OFFICE_RELS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
+_STYLES = (
+ ''
+ ''
+ ''
+ ''
+)
+
+
+@pytest.fixture(scope="module")
+def rules() -> Rules:
+ return load_rules()
+
+
+def _plain_xlsx() -> bytes:
+ book = openpyxl.Workbook()
+ sheet = book.active
+ assert sheet is not None
+ sheet.title = "ОСВ"
+ sheet["A1"] = "Оборотно-сальдовая ведомость по счету 60"
+ buffer = io.BytesIO()
+ book.save(buffer)
+ return buffer.getvalue()
+
+
+def _rewrite(data: bytes, patch: Callable[[str, bytes], bytes], extra: dict[str, bytes] | None = None) -> bytes:
+ """Копия zip-архива книги: содержимое частей через `patch(имя, байты)` и дополнительные части `extra`."""
+ source = zipfile.ZipFile(io.BytesIO(data))
+ out = io.BytesIO()
+ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as target:
+ for info in source.infolist():
+ target.writestr(info.filename, patch(info.filename, source.read(info.filename)))
+ for name, content in (extra or {}).items():
+ target.writestr(name, content)
+ return out.getvalue()
+
+
+def _xlsx_with_merges(*refs: str, tag: str = "mergeCell") -> bytes:
+ cells = "".join(f'<{tag} ref="{ref}"/>' for ref in refs).encode()
+ block = b'' % len(refs) + cells + b""
+
+ def patch(name: str, content: bytes) -> bytes:
+ return content.replace(b"", b"" + block) if name == SHEET_PART else content
+
+ return _rewrite(_plain_xlsx(), patch)
+
+
+def _xlsx_with_zeros(size: int) -> bytes:
+ return _rewrite(_plain_xlsx(), lambda _name, content: content, {"xl/media/zeros.bin": bytes(size)})
+
+
+@pytest.fixture
+def openpyxl_forbidden(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
+ """openpyxl не должен вызываться: бомба отбрасывается до него (и до выделения памяти под ячейки).
+
+ Вызов не только падает, но и запоминается: `read_book` превращает любое исключение читателя в `UnreadableError`,
+ и тест на UNREADABLE без проверки после теста прошёл бы и при вызове openpyxl.
+ """
+ calls: list[str] = []
+
+ def forbidden(*_args: Any, **_kwargs: Any) -> None:
+ calls.append("load_workbook")
+ raise AssertionError("openpyxl.load_workbook вызван: лимит должен сработать до чтения книги")
+
+ monkeypatch.setattr(openpyxl, "load_workbook", forbidden)
+ yield
+ assert calls == [], "openpyxl.load_workbook вызван: лимит должен сработать до чтения книги"
+
+
+# --- Площадь объединённых ячеек (xlsx) ---
+
+
+@pytest.mark.usefixtures("openpyxl_forbidden")
+def test_huge_merged_range_is_rejected_before_openpyxl() -> None:
+ data = _xlsx_with_merges("A1:Z200000") # 5,2 млн ячеек в нескольких килобайтах файла
+ assert len(data) < BOMB_MAX_FILE_BYTES
+
+ started = time.monotonic()
+ with pytest.raises(UnsupportedFormatError) as caught:
+ read_book(data, "XLSX")
+
+ assert time.monotonic() - started < FAST_SECONDS
+ assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
+ assert "лимит объединённых ячеек" in caught.value.message
+ assert str(MERGED_AREA_LIMIT) in caught.value.message
+
+
+@pytest.mark.usefixtures("openpyxl_forbidden")
+def test_many_small_merged_ranges_are_summed() -> None:
+ per_range = 1000 # A1:A1000 и т. д.: каждый диапазон по отдельности невелик
+ count = MERGED_AREA_LIMIT // per_range + 1
+ refs = [f"B{start}:B{start + per_range - 1}" for start in range(1, count * per_range, per_range)]
+
+ with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
+ read_book(_xlsx_with_merges(*refs), "XLSX")
+
+
+@pytest.mark.usefixtures("openpyxl_forbidden")
+def test_huge_merged_range_gives_unsupported_format_from_parser(rules: Rules) -> None:
+ outcome = OsvParser(rules, max_bytes=50 * 1024 * 1024, timeout_seconds=120).parse(
+ _xlsx_with_merges("A1:Z200000"), "XLSX"
+ )
+
+ assert outcome.status is ResultStatus.ERROR
+ assert outcome.error is not None
+ assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
+
+
+def test_normal_merges_are_read() -> None:
+ sheets, _ = read_book(_xlsx_with_merges("A3:A4", "B3:C3"), "XLSX")
+
+ assert set(sheets[0].merged) == {MergedRange(3, 1, 4, 1), MergedRange(3, 2, 3, 3)}
+
+
+@pytest.mark.parametrize("chunk_size", [1, 7, 64, 65_536])
+def test_scan_is_independent_of_chunk_boundaries(chunk_size: int) -> None:
+ xml = (
+ b'1
'
+ b''
+ b''
+ ) % (MAIN_NS.encode(), MAIN_NS.encode())
+ expected = 4 + 1 + 10 + 4
+ assert merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT, chunk_size=chunk_size) == expected
+
+
+def test_scan_limit_is_inclusive() -> None:
+ xml = b''
+ area = 100
+
+ assert merged_area_of_xml(io.BytesIO(xml), limit=area) == area
+ with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
+ merged_area_of_xml(io.BytesIO(xml), limit=area - 1)
+
+
+def test_scan_ignores_merge_cells_container_and_other_elements() -> None:
+ xml = b''
+
+ assert merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT, chunk_size=5) == 0
+
+
+def test_overlong_merge_element_is_rejected() -> None:
+ """Раздутый атрибут не прячет диапазон от сканера: слишком длинный элемент mergeCell — отказ, а не пропуск."""
+ xml = b''
+
+ with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
+ merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT, chunk_size=4096)
+
+
+# --- Обходы скана (повторное ревью I-1б): книги собираются вручную, `zipfile`, без openpyxl ---
+
+BOMB_REF = "A1:Z200000" # 5,2 млн ячеек
+SMALL_REF = "A1:B2"
+SMALL_SPAN = MergedRange(1, 1, 2, 2)
+
+
+XML_DECLARATION = ''
+
+
+def _hand_xlsx(
+ merge_cells: str,
+ *,
+ sheet_part: str = SHEET_PART,
+ extra: dict[str, bytes] | None = None,
+ sheet_head: str = XML_DECLARATION,
+ sheet_encoding: str = "utf-8",
+) -> bytes:
+ """Минимальная книга xlsx из одного листа: `merge_cells` вставляется после `sheetData`, лист лежит в `sheet_part`
+ (цель связи в `xl/_rels/workbook.xml.rels` — openpyxl идёт по ней, имя части не проверяет), начинается с
+ `sheet_head` (по умолчанию XML-декларация) и записан в кодировке `sheet_encoding`."""
+ target = sheet_part.removeprefix("xl/")
+ parts = {
+ "[Content_Types].xml": (
+ ''
+ ''
+ ''
+ ''
+ ''
+ ),
+ "_rels/.rels": (
+ f''
+ f''
+ ""
+ ),
+ "xl/workbook.xml": (
+ f''
+ ''
+ ),
+ "xl/_rels/workbook.xml.rels": (
+ f''
+ f''
+ f''
+ ),
+ "xl/styles.xml": f'{_STYLES}',
+ }
+ sheet = (
+ f'{sheet_head}'
+ 'Оборотно-сальдовая ведомость по счету 60
'
+ f"{merge_cells}"
+ )
+ out = io.BytesIO()
+ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as archive:
+ for name, content in parts.items():
+ archive.writestr(name, content)
+ archive.writestr(sheet_part, sheet.encode(sheet_encoding))
+ for name, raw in (extra or {}).items():
+ archive.writestr(name, raw)
+ return out.getvalue()
+
+
+def _merges(element: str) -> str:
+ return f'{element}'
+
+
+# Вариант → (элемент mergeCell с диапазоном SMALL_REF, тот же с BOMB_REF, часть листа)
+_BYPASSES: dict[str, tuple[str, str, str]] = {
+ "исходная бомба": (f'', f'', SHEET_PART),
+ "лист вне xl/worksheets": (f'', f'', "xl/data/s.xml"),
+ "лист без расширения .xml": (f'', f'', "xl/data/s.dat"),
+ "десятичная ссылка на символ": ('', '', SHEET_PART),
+ "шестнадцатеричная ссылка на символ": (
+ '',
+ '',
+ SHEET_PART,
+ ),
+ "атрибут a:ref перед ref": (
+ f'',
+ f'',
+ SHEET_PART,
+ ),
+ "«>» в значении атрибута": (
+ f'',
+ f'',
+ SHEET_PART,
+ ),
+ "элемент с префиксом основного пространства": (
+ f'',
+ f'',
+ SHEET_PART,
+ ),
+ "элемент чужого пространства имён": (
+ f'',
+ f'',
+ SHEET_PART,
+ ),
+}
+
+
+@pytest.mark.parametrize("variant", list(_BYPASSES))
+def test_bypass_variant_is_honoured_by_openpyxl(variant: str) -> None:
+ """Каждый вариант — не выдумка: openpyxl на малом диапазоне действительно читает объединение (значит, на
+ большом разложил бы его по ячейкам), а скан пропускает книгу в пределах лимита и видит площадь диапазона."""
+ small, _, part = _BYPASSES[variant]
+ data = _hand_xlsx(_merges(small), sheet_part=part)
+
+ sheets, _ = read_book(data, "XLSX")
+
+ assert sheets[0].merged == (SMALL_SPAN,)
+ with zipfile.ZipFile(io.BytesIO(data)) as archive, pytest.raises(UnsupportedFormatError):
+ check_xlsx_merged_area(archive, limit=SMALL_SPAN.area - 1)
+
+
+@pytest.mark.usefixtures("openpyxl_forbidden")
+@pytest.mark.parametrize("variant", list(_BYPASSES))
+def test_bypass_variant_bomb_is_rejected_before_openpyxl(variant: str) -> None:
+ _, bomb, part = _BYPASSES[variant]
+ data = _hand_xlsx(_merges(bomb), sheet_part=part)
+ assert len(data) < BOMB_MAX_FILE_BYTES
+
+ started = time.monotonic()
+ with pytest.raises(UnsupportedFormatError) as caught:
+ read_book(data, "XLSX")
+
+ assert time.monotonic() - started < FAST_SECONDS
+ assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
+ assert "лимит объединённых ячеек" in caught.value.message
+
+
+@pytest.mark.usefixtures("openpyxl_forbidden")
+def test_whole_sheet_merge_costs_one_step() -> None:
+ """Площадь считается по строке ref, ячейки не перебираются: диапазон на весь лист отвергается сразу."""
+ started = time.monotonic()
+ with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
+ read_book(_hand_xlsx(_merges('')), "XLSX")
+
+ assert time.monotonic() - started < FAST_SECONDS
+
+
+def test_unprefixed_ref_only_is_counted() -> None:
+ """Считается только атрибут `ref` без префикса (его читает openpyxl); `a:ref` с бомбой — чужой атрибут."""
+ xml = (
+ b''
+ b""
+ ) % MAIN_NS.encode()
+
+ assert merged_area_of_xml(io.BytesIO(xml), limit=MERGED_AREA_LIMIT) == SMALL_SPAN.area
+
+
+def test_scan_stops_reading_as_soon_as_limit_is_exceeded() -> None:
+ """Ранний отказ: поток после бомбы не дочитывается (и даже битый XML за ней не важен)."""
+ head = b''
+ stream = io.BytesIO(head + b"x" * 1_000_000 + b"<<<")
+
+ with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
+ merged_area_of_xml(stream, limit=MERGED_AREA_LIMIT, chunk_size=len(head))
+
+ assert stream.tell() < 2 * len(head)
+
+
+@pytest.mark.usefixtures("openpyxl_forbidden")
+def test_malformed_xml_part_is_unreadable() -> None:
+ data = _hand_xlsx(_merges('')) # элемент не закрыт
+
+ with pytest.raises(UnreadableError) as caught:
+ read_book(data, "XLSX")
+
+ assert caught.value.code is ErrorCode.UNREADABLE
+
+
+@pytest.mark.usefixtures("openpyxl_forbidden")
+@pytest.mark.parametrize("element", ['', '', ""])
+def test_unparseable_merge_ref_is_unreadable(element: str) -> None:
+ """ref, который openpyxl не разберёт (или его нет), — книга не читается, как и у openpyxl."""
+ with pytest.raises(UnreadableError, match="mergeCell"):
+ read_book(_hand_xlsx(_merges(element)), "XLSX")
+
+
+def test_non_xml_parts_that_are_not_xml_do_not_break_reading() -> None:
+ """Части без расширения .xml сканируются все, но мягко: бинарные (картинки, printerSettings), текст и не вполне
+ корректный VML книгу не ломают — ошибка разбора лишь заканчивает скан части (openpyxl их не разбирает)."""
+ extra = {
+ "xl/media/image1.png": b"\x89PNG\r\n\x1a\n" + bytes(64),
+ "xl/printerSettings/printerSettings1.bin": "\\\\принтер".encode("utf-16-le") + bytes(64),
+ "xl/drawings/vmlDrawing1.vml": b"
",
+ "xl/notes.txt": b" " * 100 + b"plain text, not XML",
+ "xl/media/empty.bin": b"",
+ }
+
+ sheets, _ = read_book(_hand_xlsx(_merges(f''), extra=extra), "XLSX")
+
+ assert sheets[0].merged == (SMALL_SPAN,)
+
+
+@pytest.mark.usefixtures("openpyxl_forbidden")
+def test_utf16_part_is_scanned() -> None:
+ """Кодировка части не прячет диапазон: expat сам определяет UTF-16 по BOM."""
+ bomb = _merges(f'')
+ sheet = f'{bomb}'
+ data = _hand_xlsx("", extra={"xl/data/u.bin": sheet.encode("utf-16")})
+
+ with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
+ read_book(data, "XLSX")
+
+
+# Обход «sniff» (повторное ревью раунда 2, I-1б): лист в части без `.xml`, ≥ 64 байт ведущих пробелов, без
+# XML-декларации — первые 64 байта не начинаются с «<», и проверка «похоже на XML» часть пропускала (27 с / 1,4 ГБ)
+CRAFTED_PART = "xl/worksheets/sheet1.dat"
+CRAFTED_PADDING = " " * 64
+SLOW_READ_SECONDS = 2.0
+# Вариант → (начало части листа, кодировка)
+_SNIFF_BYPASSES: dict[str, tuple[str, str]] = {
+ "64 пробела без декларации": (CRAFTED_PADDING, "utf-8"),
+ "BOM UTF-16 и 64 пробела без декларации": (CRAFTED_PADDING, "utf-16"),
+ "4096 пробелов без декларации": (" " * 4096, "utf-8"),
+}
+
+
+@pytest.mark.parametrize("variant", list(_SNIFF_BYPASSES))
+def test_padded_non_xml_sheet_part_is_honoured_by_openpyxl(variant: str) -> None:
+ """Вариант не выдумка: openpyxl читает такой лист и раскладывает его объединения."""
+ head, encoding = _SNIFF_BYPASSES[variant]
+ data = _hand_xlsx(
+ _merges(f''), sheet_part=CRAFTED_PART, sheet_head=head, sheet_encoding=encoding
+ )
+
+ sheets, _ = read_book(data, "XLSX")
+
+ assert sheets[0].merged == (SMALL_SPAN,)
+ with zipfile.ZipFile(io.BytesIO(data)) as archive, pytest.raises(UnsupportedFormatError):
+ check_xlsx_merged_area(archive, limit=SMALL_SPAN.area - 1)
+
+
+@pytest.mark.usefixtures("openpyxl_forbidden")
+@pytest.mark.parametrize("variant", list(_SNIFF_BYPASSES))
+def test_padded_non_xml_sheet_part_bomb_is_rejected_before_openpyxl(variant: str) -> None:
+ head, encoding = _SNIFF_BYPASSES[variant]
+ data = _hand_xlsx(
+ _merges(f''), sheet_part=CRAFTED_PART, sheet_head=head, sheet_encoding=encoding
+ )
+ assert len(data) < BOMB_MAX_FILE_BYTES
+
+ started = time.monotonic()
+ with pytest.raises(UnsupportedFormatError) as caught:
+ read_book(data, "XLSX")
+
+ assert time.monotonic() - started < SLOW_READ_SECONDS
+ assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
+ assert "лимит объединённых ячеек" in caught.value.message
+
+
+# --- Площадь объединённых ячеек после чтения (xls/xlsb: индекс merged в `Sheet`) ---
+
+
+def _sheet_with(*spans: MergedRange) -> Sheet:
+ return Sheet(name="Лист", cells={}, merged=spans, row_outline={}, n_rows=1, n_cols=1)
+
+
+def _indexable_spans(count: int) -> tuple[MergedRange, ...]:
+ """`count` диапазонов площадью ровно `MAX_INDEXED_AREA` (столбец за столбцом): каждый индекс раскладывает."""
+ return tuple(MergedRange(1, col, MAX_INDEXED_AREA, col) for col in range(1, count + 1))
+
+
+def test_merged_area_of_read_sheets_is_limited() -> None:
+ """xlrd хранит merged списком (8 байт на диапазон), но индекс `Sheet` раскладывает диапазоны по ячейкам:
+ тысячи средних диапазонов в xls дали бы десятки миллионов записей. Та же сумма площадей — после чтения.
+ """
+ half = _indexable_spans(MERGED_AREA_LIMIT // MAX_INDEXED_AREA // 2)
+ check_merged_area([_sheet_with(*half), _sheet_with(*half)], limit=MERGED_AREA_LIMIT)
+
+ with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
+ check_merged_area([_sheet_with(*half), _sheet_with(*half, MergedRange(1, 99, 1, 99))], limit=MERGED_AREA_LIMIT)
+
+
+def test_spans_not_indexed_by_sheet_are_not_counted_after_read() -> None:
+ """Диапазон больше `MAX_INDEXED_AREA` (merged на весь столбец, на весь лист) `Sheet` по ячейкам не раскладывает —
+ риска нет, и законная книга xls с таким диапазоном не отвергается (находка N1)."""
+ whole_column = MergedRange(1, 1, 1_048_576, 1)
+ whole_sheet = MergedRange(1, 1, 1_048_576, 16_384)
+ just_above = MergedRange(1, 1, MAX_INDEXED_AREA + 1, 1)
+
+ check_merged_area([_sheet_with(whole_column, whole_sheet, just_above)], limit=MERGED_AREA_LIMIT)
+ with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
+ check_merged_area([_sheet_with(whole_column, *_indexable_spans(MERGED_AREA_LIMIT // MAX_INDEXED_AREA + 1))])
+
+
+def test_xls_reader_result_is_checked(monkeypatch: pytest.MonkeyPatch) -> None:
+ many = _sheet_with(*_indexable_spans(MERGED_AREA_LIMIT // MAX_INDEXED_AREA + 1))
+ monkeypatch.setitem(_READERS, "XLS", ("BIFF8", lambda _data: (many,)))
+
+ with pytest.raises(UnsupportedFormatError, match="лимит объединённых ячеек"):
+ read_book(bytes.fromhex("D0CF11E0A1B11AE1") + b"\x00" * 64, "XLS")
+
+
+def test_xls_with_whole_column_merge_is_read(monkeypatch: pytest.MonkeyPatch) -> None:
+ sheet = _sheet_with(MergedRange(1, 1, 1_048_576, 1))
+ monkeypatch.setitem(_READERS, "XLS", ("BIFF8", lambda _data: (sheet,)))
+
+ sheets, _ = read_book(bytes.fromhex("D0CF11E0A1B11AE1") + b"\x00" * 64, "XLS")
+
+ assert sheets == (sheet,)
+
+
+def _large_spans(count: int) -> tuple[MergedRange, ...]:
+ """`count` диапазонов площадью `MAX_INDEXED_AREA + 1` (столбец за столбцом): индекс не раскладывает ни один."""
+ return tuple(MergedRange(1, col, MAX_INDEXED_AREA + 1, col) for col in range(1, count + 1))
+
+
+def test_large_spans_count_is_limited_per_sheet() -> None:
+ """Крупные диапазоны (площадь больше `MAX_INDEXED_AREA`) `Sheet.merged_span` перебирает линейно на каждое
+ обращение: в xls их 100 000 — ≈ 4,4 мс на обращение (повторное ревью раунда 2, минор #2). Потолок — на лист."""
+ at_limit = _large_spans(LARGE_MERGED_COUNT_LIMIT)
+ check_merged_area([_sheet_with(*at_limit), _sheet_with(*at_limit)])
+
+ with pytest.raises(UnsupportedFormatError, match="лимит крупных объединённых диапазонов") as caught:
+ check_merged_area([_sheet_with(*_large_spans(LARGE_MERGED_COUNT_LIMIT + 1))])
+ assert str(LARGE_MERGED_COUNT_LIMIT) in caught.value.message
+
+
+def test_xls_with_too_many_large_spans_is_unsupported(monkeypatch: pytest.MonkeyPatch) -> None:
+ many = _sheet_with(*_large_spans(100_000))
+ monkeypatch.setitem(_READERS, "XLS", ("BIFF8", lambda _data: (many,)))
+
+ started = time.monotonic()
+ with pytest.raises(UnsupportedFormatError) as caught:
+ read_book(bytes.fromhex("D0CF11E0A1B11AE1") + b"\x00" * 64, "XLS")
+
+ assert time.monotonic() - started < FAST_SECONDS
+ assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
+ assert "лимит крупных объединённых диапазонов" in caught.value.message
+
+
+# --- Распакованный размер zip ---
+
+
+@pytest.mark.usefixtures("openpyxl_forbidden")
+def test_zip_expanding_beyond_limit_is_rejected() -> None:
+ data = _xlsx_with_zeros(ZEROS_BYTES)
+ assert len(data) < SMALL_MAX_BYTES
+
+ with pytest.raises(UnsupportedFormatError) as caught:
+ read_book(data, "XLSX", max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * SMALL_MAX_BYTES)
+
+ assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
+ assert "распакованный размер" in caught.value.message
+ assert str(ZIP_UNCOMPRESSED_LIMIT_FACTOR * SMALL_MAX_BYTES) in caught.value.message
+
+
+@pytest.mark.usefixtures("openpyxl_forbidden")
+def test_parser_passes_ten_times_max_bytes_as_zip_limit(rules: Rules) -> None:
+ outcome = OsvParser(rules, max_bytes=SMALL_MAX_BYTES, timeout_seconds=120).parse(
+ _xlsx_with_zeros(ZEROS_BYTES), "XLSX"
+ )
+
+ assert outcome.error is not None
+ assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
+ assert "распакованный размер" in outcome.error.message
+
+
+def test_zip_limit_applies_to_xlsb_too() -> None:
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
+ archive.writestr("xl/workbook.bin", bytes(ZEROS_BYTES))
+
+ with pytest.raises(UnsupportedFormatError, match="распакованный размер"):
+ read_book(buffer.getvalue(), "XLSB", max_uncompressed_bytes=SMALL_MAX_BYTES)
+
+
+def test_zip_within_limit_is_read() -> None:
+ data = _xlsx_with_zeros(1000)
+
+ sheets, _ = read_book(data, "XLSX", max_uncompressed_bytes=ZIP_UNCOMPRESSED_LIMIT_FACTOR * SMALL_MAX_BYTES)
+
+ assert [sheet.name for sheet in sheets] == ["ОСВ"]
diff --git a/api/tests/unit/parser/test_readers.py b/api/tests/unit/parser/test_readers.py
new file mode 100644
index 0000000..4c689ea
--- /dev/null
+++ b/api/tests/unit/parser/test_readers.py
@@ -0,0 +1,191 @@
+"""Читатели книг: синтетическая книга openpyxl в памяти → `Sheet`; определение формата по сигнатуре; ошибки чтения."""
+
+import datetime
+import io
+import zipfile
+from collections.abc import Iterator
+from decimal import Decimal
+from types import TracebackType
+from typing import NamedTuple
+
+import openpyxl
+import pytest
+from openpyxl.styles import Alignment, Font
+
+from src.contract import ErrorCode
+from src.parser.errors import ParserError, UnreadableError, UnsupportedFormatError
+from src.parser.grid import EMPTY_CELL, Cell, MergedRange
+from src.parser.readers import read_book
+from src.parser.readers import xlsb as xlsb_reader
+
+
+def _synthetic_xlsx() -> bytes:
+ """Две страницы: шапка с merged-заголовками, outline 0/1/2, bold, отступ, число, дата, bool, пустые строки,
+ ячейки из пробелов и NBSP, merged-диапазон с пробельным origin."""
+ book = openpyxl.Workbook()
+ osv = book.active
+ assert osv is not None
+ osv.title = "ОСВ"
+ osv["A1"] = "ООО Ромашка"
+ osv["A1"].font = Font(bold=True)
+ osv["A3"] = "Счет"
+ osv.merge_cells("A3:A4")
+ osv["B3"] = "Сальдо на начало периода"
+ osv.merge_cells("B3:C3")
+ osv["B4"] = "Дебет"
+ osv["C4"] = "Кредит"
+ osv["A5"] = "62"
+ osv["A5"].font = Font(bold=True)
+ osv["B5"] = 1500
+ osv["A6"] = "62.01"
+ osv["A6"].alignment = Alignment(indent=2)
+ osv["B6"] = 1500.25
+ osv.row_dimensions[6].outline_level = 1
+ osv["A7"] = " Контрагент"
+ osv["B7"] = datetime.datetime(2024, 9, 30)
+ osv.row_dimensions[7].outline_level = 2
+ osv["A9"] = " "
+ osv["B9"] = "\xa0"
+ osv["D3"] = " "
+ osv.merge_cells("D3:E3")
+ osv["A10"] = "Итого"
+ osv["C10"] = True
+ other = book.create_sheet("Прочее")
+ other["A1"] = "x"
+ buffer = io.BytesIO()
+ book.save(buffer)
+ return buffer.getvalue()
+
+
+def test_xlsx_reader_builds_sheet_model() -> None:
+ sheets, warnings = read_book(_synthetic_xlsx(), "XLSX")
+
+ assert warnings == []
+ assert [sheet.name for sheet in sheets] == ["ОСВ", "Прочее"]
+ osv = sheets[0]
+ assert osv.cell(1, 1) == Cell("ООО Ромашка", "text", bold=True)
+ assert osv.cell(5, 1) == Cell("62", "text", bold=True)
+ assert osv.cell(5, 2) == Cell(Decimal("1500"), "number")
+ assert osv.cell(6, 1) == Cell("62.01", "text", indent=2)
+ assert osv.cell(6, 2) == Cell(Decimal("1500.25"), "number")
+ assert osv.cell(7, 1) == Cell(" Контрагент", "text", indent=3)
+ assert osv.cell(7, 2) == Cell(datetime.datetime(2024, 9, 30), "date")
+ assert osv.cell(10, 3) == Cell(True, "bool")
+ assert osv.cell(9, 1) is EMPTY_CELL
+ assert osv.cell(9, 2) is EMPTY_CELL
+ assert (9, 1) not in osv.cells
+ assert (9, 2) not in osv.cells
+ assert osv.is_row_empty(8)
+ assert osv.is_row_empty(9)
+ assert osv.text_at(3, 5) == ""
+ assert osv.merged_origin(3, 5) == (3, 4)
+ assert set(osv.merged) == {MergedRange(3, 1, 4, 1), MergedRange(3, 2, 3, 3), MergedRange(3, 4, 3, 5)}
+ assert osv.text_at(4, 1) == "Счет"
+ assert osv.text_at(3, 3) == "Сальдо на начало периода"
+ assert [osv.outline(row) for row in range(1, 11)] == [0, 0, 0, 0, 0, 1, 2, 0, 0, 0]
+ assert (osv.n_rows, osv.n_cols) == (10, 5)
+ assert all(cell.kind != "empty" for cell in osv.cells.values())
+ assert sheets[1].row_cells(1) == [(1, Cell("x", "text"))]
+
+
+def test_file_type_mismatch_reads_by_signature_with_warning() -> None:
+ sheets, warnings = read_book(_synthetic_xlsx(), "XLS")
+
+ assert [sheet.name for sheet in sheets] == ["ОСВ", "Прочее"]
+ assert warnings == ["file_type=XLS, но содержимое OOXML: прочитано как XLSX"]
+
+
+def test_garbage_bytes_are_unsupported_format() -> None:
+ with pytest.raises(UnsupportedFormatError) as caught:
+ read_book(b"%PDF-1.7 not a workbook", "XLSX")
+
+ assert caught.value.code is ErrorCode.UNSUPPORTED_FORMAT
+ assert isinstance(caught.value, ParserError)
+
+
+def test_truncated_zip_is_unreadable_with_one_line_reason() -> None:
+ data = _synthetic_xlsx()
+
+ with pytest.raises(UnreadableError) as caught:
+ read_book(data[: len(data) // 2], "XLSX")
+
+ assert caught.value.code is ErrorCode.UNREADABLE
+ assert caught.value.message
+ assert "\n" not in caught.value.message
+
+
+def test_truncated_biff_is_unreadable() -> None:
+ with pytest.raises(UnreadableError):
+ read_book(bytes.fromhex("D0CF11E0A1B11AE1") + b"\x00" * 64, "XLS")
+
+
+def _zip_with(name: str) -> bytes:
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w") as archive:
+ archive.writestr(name, b"\x00")
+ return buffer.getvalue()
+
+
+class _XlsbCell(NamedTuple):
+ r: int
+ c: int
+ v: object
+
+
+class _FakeXlsbSheet:
+ def __init__(self, rows: list[list[_XlsbCell]]) -> None:
+ self._rows = rows
+
+ def __enter__(self) -> "_FakeXlsbSheet":
+ return self
+
+ def __exit__(self, *exc: object) -> None:
+ return None
+
+ def rows(self, sparse: bool = False) -> Iterator[list[_XlsbCell]]:
+ assert sparse
+ yield from self._rows
+
+
+class _FakeXlsbBook:
+ sheets = ("Лист1",)
+
+ def __enter__(self) -> "_FakeXlsbBook":
+ return self
+
+ def __exit__(
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
+ ) -> None:
+ return None
+
+ def get_sheet(self, name: str) -> _FakeXlsbSheet:
+ assert name == "Лист1"
+ return _FakeXlsbSheet(
+ [
+ [_XlsbCell(0, 0, "Счет"), _XlsbCell(0, 1, None), _XlsbCell(0, 2, " ")],
+ [_XlsbCell(2, 0, "62.01"), _XlsbCell(2, 1, 0.1), _XlsbCell(2, 2, False)],
+ ]
+ )
+
+
+def test_xlsb_signature_is_read_by_pyxlsb_without_outline(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(xlsb_reader.pyxlsb, "open_workbook", lambda _stream: _FakeXlsbBook())
+
+ sheets, warnings = read_book(_zip_with("xl/workbook.bin"), "XLSB")
+
+ assert warnings == ["xlsb: outline level недоступен"]
+ (sheet,) = sheets
+ assert sheet.name == "Лист1"
+ assert sheet.cell(1, 1) == Cell("Счет", "text")
+ assert sheet.cell(1, 3) is EMPTY_CELL
+ assert (1, 3) not in sheet.cells
+ assert sheet.cell(3, 2) == Cell(Decimal("0.1"), "number")
+ assert sheet.cell(3, 3) == Cell(False, "bool")
+ assert sheet.merged == ()
+ assert sheet.row_outline == {}
+ assert (sheet.n_rows, sheet.n_cols) == (3, 3)
+
+
+def test_zip_without_workbook_bin_goes_to_openpyxl_and_is_unreadable() -> None:
+ with pytest.raises(UnreadableError):
+ read_book(_zip_with("hello.txt"), "XLSX")
diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py
new file mode 100644
index 0000000..e914873
--- /dev/null
+++ b/api/tests/unit/parser/test_rules.py
@@ -0,0 +1,556 @@
+"""Словари `rules/*.yaml` и загрузчик: нормализация текста, паттерны, таблица периодов §1.5, группировки §1.1,
+типы данных §1.3, диагностика битых словарей."""
+
+import shutil
+from dataclasses import fields, is_dataclass
+from pathlib import Path
+
+import pytest
+import yaml
+from pydantic import BaseModel
+
+from src.parser.rules import (
+ DEFAULT_RULES_DIR,
+ OsvGroupRules,
+ Pattern,
+ Rules,
+ first_match,
+ load_rules,
+ normalize_text,
+)
+
+# Символы, которых не должно быть в паттернах словарей: нормализованный текст их не содержит (ё → е, дефисы и
+# неразрывные пробелы → пробел), такой паттерн никогда бы не сработал
+MIN_PATTERNS = 40 # защита от «пустого» обхода дерева словарей
+ISO_CODE_LENGTH = 3
+FORBIDDEN_IN_PATTERN = "ёЁ-\u2010\u2011\u2012\u2013\u2014\u2015\u2212\u00a0\n\r\t"
+
+
+@pytest.fixture(scope="module")
+def rules() -> Rules:
+ return load_rules()
+
+
+def _all_patterns(node: object) -> list[Pattern]:
+ """Все `Pattern` в дереве словарей (модели, списки, кортежи, словари)."""
+ if isinstance(node, Pattern):
+ return [node]
+ if isinstance(node, BaseModel):
+ return _all_patterns(list(dict(node).values()))
+ if is_dataclass(node) and not isinstance(node, type):
+ return _all_patterns([getattr(node, field.name) for field in fields(node)])
+ if isinstance(node, dict):
+ return _all_patterns(list(node.values()))
+ if isinstance(node, list | tuple):
+ return [found for item in node for found in _all_patterns(item)]
+ return []
+
+
+def _copy_rules(tmp_path: Path) -> Path:
+ target = tmp_path / "rules"
+ shutil.copytree(DEFAULT_RULES_DIR, target, ignore=shutil.ignore_patterns("README.md"))
+ return target
+
+
+# --- normalize_text ---
+
+
+@pytest.mark.parametrize(
+ ("raw", "expected"),
+ [
+ ("Оборотно-сальдовая\nведомость", "оборотно сальдовая ведомость"),
+ ("ОБОРОТНО\u2013САЛЬДОВАЯ", "оборотно сальдовая"),
+ ("Оборотно\u2014сальдовая", "оборотно сальдовая"),
+ ("Счёт\u00a060", "счет 60"),
+ (" Сальдо \t на начало ", "сальдо на начало"),
+ ("Оборотно-\nсальдовая", "оборотно сальдовая"),
+ ("сальдо\u00adвая", "сальдовая"),
+ ("Д-т", "д т"),
+ ("", ""),
+ (" ", ""),
+ ],
+)
+def test_normalize_text(raw: str, expected: str) -> None:
+ assert normalize_text(raw) == expected
+
+
+# --- Pattern ---
+
+
+def test_pattern_matches_normalized_text() -> None:
+ pattern = Pattern.compile("оборотно сальдовая ведомость")
+
+ assert pattern.search("ОБОРОТНО-САЛЬДОВАЯ\nВедомость по счету 60") is not None
+ assert pattern.search("Ведомость") is None
+
+
+def test_pattern_returns_match_object_for_position_arithmetic() -> None:
+ match = Pattern.compile(r"по\s+счету").search("Оборотно-сальдовая ведомость по счёту 58.03 за 2024 г.")
+
+ assert match is not None
+ # Позиции считаются по нормализованному тексту: match.string — он же
+ assert match.string[match.start() : match.end()] == "по счету"
+ assert match.string[match.end() :].strip().startswith("58.03")
+
+
+def test_search_normalized_matches_prenormalized_text_without_renormalizing() -> None:
+ pattern = Pattern.compile("оборотно сальдовая ведомость")
+ normalized = normalize_text("ОБОРОТНО-САЛЬДОВАЯ\nВедомость")
+
+ assert pattern.search_normalized(normalized) is not None
+ # Текст не нормализуется повторно: «сырая» строка с дефисом и заглавными не совпадает
+ assert pattern.search_normalized("ОБОРОТНО-САЛЬДОВАЯ ведомость") is None
+
+
+@pytest.mark.parametrize("bad", ["сальдо на начало-периода", "счёт", "сальдо\nна", "[а-я]+"])
+def test_pattern_rejects_sources_that_normalization_would_never_match(bad: str) -> None:
+ with pytest.raises(ValueError, match="нормализ"):
+ Pattern.compile(bad)
+
+
+def test_pattern_rejects_invalid_regex() -> None:
+ with pytest.raises(ValueError, match="регуляр"):
+ Pattern.compile("(незакрытая")
+
+
+def test_shipped_patterns_are_written_in_normalized_form(rules: Rules) -> None:
+ patterns = _all_patterns(rules)
+
+ assert len(patterns) > MIN_PATTERNS
+ offenders = [p.source for p in patterns if any(ch in p.source for ch in FORBIDDEN_IN_PATTERN)]
+ assert offenders == []
+
+
+# --- Загрузка ---
+
+
+def test_load_rules_reads_all_four_dictionaries(rules: Rules) -> None:
+ assert rules.headers.sheet_markers
+ assert set(rules.headers.groups) == {"opening", "turnover", "closing"}
+ assert rules.groups.mapping
+ assert rules.periods.rules
+ assert rules.data_types.header
+
+
+def test_new_synonym_is_a_data_edit_not_a_code_edit(tmp_path: Path) -> None:
+ # Синоним из слов, которых нет в штатном словаре, добавляется строкой в YAML; код не меняется
+ target = _copy_rules(tmp_path)
+ path = target / "osv_headers.yaml"
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
+ data["groups"]["opening"].append("перенос с прошлого периода")
+ path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
+
+ loaded = load_rules(target)
+
+ assert first_match([(p, True) for p in loaded.headers.groups["opening"]], "Перенос с прошлого периода") is True
+ assert (
+ first_match([(p, True) for p in load_rules().headers.groups["opening"]], "Перенос с прошлого периода") is None
+ )
+
+
+def test_missing_required_key_raises_runtime_error_with_file_and_key(tmp_path: Path) -> None:
+ target = _copy_rules(tmp_path)
+ path = target / "osv_headers.yaml"
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
+ del data["groups"]
+ path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
+
+ with pytest.raises(RuntimeError) as error:
+ load_rules(target)
+
+ assert "osv_headers.yaml" in str(error.value)
+ assert "groups" in str(error.value)
+
+
+def test_periods_without_end_marker_raises_runtime_error_with_file_and_key(tmp_path: Path) -> None:
+ target = _copy_rules(tmp_path)
+ path = target / "periods.yaml"
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
+ del data["end"]
+ path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
+
+ with pytest.raises(RuntimeError) as error:
+ load_rules(target)
+
+ assert "periods.yaml" in str(error.value)
+ assert "end" in str(error.value)
+
+
+def test_default_period_end_marker_cuts_at_year_abbreviation_and_year_word(rules: Rules) -> None:
+ end = rules.periods.end
+
+ assert end.search("3 квартал 2024 г. (по подразделениям)") is not None
+ assert end.search("2024 год") is not None
+ assert end.search("с 01.01.2025 по 31.03.2025") is None
+
+
+def test_unknown_key_is_rejected_as_probable_typo(tmp_path: Path) -> None:
+ target = _copy_rules(tmp_path)
+ path = target / "periods.yaml"
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
+ data["rulez"] = []
+ path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
+
+ with pytest.raises(RuntimeError, match=r"periods\.yaml.*rulez"):
+ load_rules(target)
+
+
+def test_broken_yaml_syntax_raises_runtime_error_with_file(tmp_path: Path) -> None:
+ target = _copy_rules(tmp_path)
+ (target / "data_types.yaml").write_text("header: [\n - : :", encoding="utf-8")
+
+ with pytest.raises(RuntimeError, match=r"data_types\.yaml"):
+ load_rules(target)
+
+
+def test_missing_file_raises_runtime_error_with_file(tmp_path: Path) -> None:
+ target = _copy_rules(tmp_path)
+ (target / "osv_groups.yaml").unlink()
+
+ with pytest.raises(RuntimeError, match=r"osv_groups\.yaml"):
+ load_rules(target)
+
+
+def test_pattern_with_yo_in_yaml_is_reported_with_file_and_key(tmp_path: Path) -> None:
+ target = _copy_rules(tmp_path)
+ path = target / "osv_headers.yaml"
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
+ data["total_row"].append("всё")
+ path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
+
+ with pytest.raises(RuntimeError, match=r"osv_headers\.yaml.*total_row"):
+ load_rules(target)
+
+
+def test_unknown_group_type_is_rejected(tmp_path: Path) -> None:
+ target = _copy_rules(tmp_path)
+ path = target / "osv_groups.yaml"
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
+ data["mapping"].append(["^склад", "WAREHOUSE"])
+ path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8")
+
+ with pytest.raises(RuntimeError, match=r"osv_groups\.yaml.*mapping"):
+ load_rules(target)
+
+
+# --- Заголовки таблицы (§1.0 п. 1–3, §1.1) ---
+
+REAL_GROUP_HEADERS = [
+ ("Сальдо на начало периода", "opening"),
+ ("Обороты за период", "turnover"),
+ ("Сальдо на конец периода", "closing"),
+ ("Нач. сальдо", "opening"),
+ ("Нач.сальдо", "opening"),
+ ("Входящее сальдо", "opening"),
+ ("Обороты", "turnover"),
+ ("Оборот за период", "turnover"),
+ ("Кон. сальдо", "closing"),
+ ("Исходящее сальдо", "closing"),
+ ("Сальдо\nна конец\nпериода", "closing"),
+]
+
+
+@pytest.mark.parametrize(("text", "group"), REAL_GROUP_HEADERS)
+def test_group_headers_match_their_group_only(rules: Rules, text: str, group: str) -> None:
+ matched = {name for name, patterns in rules.headers.groups.items() if any(p.search(text) for p in patterns)}
+
+ assert matched == {group}
+
+
+def test_sheet_title_is_not_a_turnover_header(rules: Rules) -> None:
+ title = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
+
+ assert not any(p.search(title) for p in rules.headers.groups["turnover"])
+ assert not any(p.search(title) for p in rules.headers.groups["opening"])
+ assert any(p.search(title) for p in rules.headers.sheet_markers)
+
+
+def test_initial_balance_synonym_is_not_in_the_default_dictionary(rules: Rules) -> None:
+ # «Нач. остаток» добавляет сценарий «новый синоним без релиза кода» (R-8): в штатном словаре его быть не должно
+ for text in ("Нач. остаток", "Начальный остаток", "Остаток на начало периода"):
+ assert not any(p.search(text) for p in rules.headers.groups["opening"]), text
+
+
+@pytest.mark.parametrize("text", ["Дебет", "Дт", "Д-т", "дебет, руб."])
+def test_debit_synonyms(rules: Rules, text: str) -> None:
+ assert any(p.search(text) for p in rules.headers.debit)
+ assert not any(p.search(text) for p in rules.headers.credit)
+
+
+@pytest.mark.parametrize("text", ["Кредит", "Кт", "К-т"])
+def test_credit_synonyms(rules: Rules, text: str) -> None:
+ assert any(p.search(text) for p in rules.headers.credit)
+ assert not any(p.search(text) for p in rules.headers.debit)
+
+
+@pytest.mark.parametrize("text", ["Бухгалтерский баланс", "Форма по ОКУД 0710001", "Отчёт о финансовых результатах"])
+def test_statement_markers(rules: Rules, text: str) -> None:
+ assert any(p.search(text) for p in rules.headers.statement_markers)
+ assert not any(p.search(text) for p in rules.headers.sheet_markers)
+
+
+@pytest.mark.parametrize(
+ ("kind", "text"),
+ [
+ ("account", "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."),
+ ("account", "Оборотно-сальдовая ведомость по счёту 58.03"),
+ ("period", "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."),
+ ("period", "Период: 1 квартал 2026 г."),
+ ("data_type", "Выводимые данные: БУ (данные бухгалтерского учета)"),
+ ("data_type", "Выводимые данные: сумма"),
+ ("currency", "Валюта БУ, НУ: руб."),
+ ("org_params", "Параметры:"),
+ ("subkonto", "Детализация по субсчетам, субконто: Контрагенты, Договоры"),
+ ("subkonto", "Субконто"),
+ ],
+)
+def test_header_markers(rules: Rules, kind: str, text: str) -> None:
+ assert any(p.search(text) for p in rules.headers.header_markers[kind]) # type: ignore[index]
+
+
+def test_account_marker_ends_right_before_the_account_code(rules: Rules) -> None:
+ text = "Оборотно-сальдовая ведомость по счёту 58.03 за 3 квартал 2024 г."
+ match = next(m for p in rules.headers.header_markers["account"] if (m := p.search(text)))
+
+ assert match.string[match.end() :].lstrip().startswith("58.03")
+
+
+@pytest.mark.parametrize(
+ ("kind", "text"),
+ [
+ ("inn", "ИНН"),
+ ("inn", "Контрагенты, ИНН"),
+ ],
+)
+def test_service_columns(rules: Rules, kind: str, text: str) -> None:
+ assert any(p.search(text) for p in rules.headers.service_columns[kind])
+
+
+def test_service_columns_hold_only_what_the_code_reads(rules: Rules) -> None:
+ """R-22: КПП, код, количество и валюта словаря не требуют — они идут в `extra` под своим заголовком."""
+ assert list(rules.headers.service_columns) == ["inn"]
+
+
+@pytest.mark.parametrize("text", ["Итого", "ИТОГО", "Итого по счету"])
+def test_total_row(rules: Rules, text: str) -> None:
+ assert any(p.search(text) for p in rules.headers.total_row)
+
+
+def test_counterparty_named_like_total_is_not_a_total_row(rules: Rules) -> None:
+ assert not any(p.search("ООО Итого-Сервис Групп") for p in rules.headers.total_row)
+
+
+# --- Группировки (§1.1) ---
+
+
+@pytest.mark.parametrize(
+ ("text", "expected"),
+ [
+ ("Счет", "ACCOUNT"),
+ ("Счета", "ACCOUNT"),
+ ("Субконто", "ACCOUNT"),
+ ("Подразделение", "DEPARTMENT"),
+ ("Подразделения", "DEPARTMENT"),
+ ("Контрагент", "COUNTERPARTY"),
+ ("Контрагенты", "COUNTERPARTY"),
+ ("Контрагенты, ИНН", "COUNTERPARTY"),
+ ("Договор", "CONTRACT"),
+ ("Договоры", "CONTRACT"),
+ ("Договоры контрагентов", "CONTRACT"),
+ ("Номенклатура", "OTHER"),
+ ("Валюта", "OTHER"),
+ ("Статьи затрат", "OTHER"),
+ ("Основные средства", "OTHER"),
+ ("", "OTHER"),
+ ],
+)
+def test_group_type_mapping(rules: Rules, text: str, expected: str) -> None:
+ assert rules.groups.classify(text) == expected
+
+
+@pytest.mark.parametrize(
+ "text",
+ [
+ "Основные средства",
+ "Номенклатура",
+ "Статьи затрат",
+ "Статьи движения денежных средств",
+ "Валюты",
+ "Нематериальные активы",
+ ],
+)
+def test_known_other_groupings_are_listed_explicitly(rules: Rules, text: str) -> None:
+ # Явная запись OTHER (а не `default`) нужна детектору: строка блока заголовка с таким текстом — метка группировки,
+ # а не первая строка данных (реальная формулировка «Основные средства» — ОСВ по счету 01)
+ assert first_match(rules.groups.mapping, text) == "OTHER"
+
+
+def test_group_mapping_is_ordered_pairs_and_first_match_wins() -> None:
+ rules = OsvGroupRules.model_validate(
+ {"mapping": [["^договор", "CONTRACT"], ["договор", "OTHER"]], "default": "ACCOUNT"}
+ )
+
+ assert rules.classify("Договоры") == "CONTRACT"
+ assert rules.classify("Прочее") == "ACCOUNT"
+
+
+# --- Периоды (§1.5) ---
+
+# Каждая строка таблицы §1.5 (все перечисленные в ней написания) → (квартал, нарастающий)
+PERIOD_TABLE = [
+ ("1 квартал", "Q1", True),
+ ("I кв.", "Q1", True),
+ ("3 месяца", "Q1", True),
+ ("январь-март", "Q1", True),
+ ("01.01–31.03", "Q1", True),
+ ("2 квартал", "Q2", False),
+ ("II кв.", "Q2", False),
+ ("полугодие", "Q2", True),
+ ("1 полугодие", "Q2", True),
+ ("6 месяцев", "Q2", True),
+ ("январь-июнь", "Q2", True),
+ ("3 квартал", "Q3", False),
+ ("III кв.", "Q3", False),
+ ("9 месяцев", "Q3", True),
+ ("январь-сентябрь", "Q3", True),
+ ("4 квартал", "Q4", False),
+ ("IV кв.", "Q4", False),
+ ("год", "Q4", True),
+ ("за 2025 год", "Q4", True),
+ ("12 месяцев", "Q4", True),
+ ("годовая", "Q4", True),
+]
+
+
+@pytest.mark.parametrize(("text", "quarter", "cumulative"), PERIOD_TABLE)
+def test_period_table_1_5(rules: Rules, text: str, quarter: str, cumulative: bool) -> None:
+ rule = rules.periods.match(text)
+
+ assert rule is not None, text
+ assert (rule.quarter, rule.is_cumulative) == (quarter, cumulative)
+
+
+@pytest.mark.parametrize(
+ ("text", "quarter", "cumulative"),
+ [
+ # Реальные формулировки заголовков фикстур (шапка «за …», как в файле)
+ ("Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г.", "Q1", True),
+ ("Оборотно-сальдовая ведомость по счету 60 за 2 квартал 2024 г.", "Q2", False),
+ ("Оборотно-сальдовая ведомость по счету 62 за 3 квартал 2025 г.", "Q3", False),
+ ("Оборотно-сальдовая ведомость по счету 66 за 4 квартал 2024 г.", "Q4", False),
+ ("Оборотно-сальдовая ведомость по счету 01 за 2024 г.", "Q4", True),
+ ("1 квартал 2025 г.", "Q1", True),
+ ("2024 г.", "Q4", True),
+ ("2024", "Q4", True),
+ # Варианты написания и порядок специфичных правил
+ ("за 9 месяцев 2024 года", "Q3", True),
+ ("за 9 мес. 2024", "Q3", True),
+ ("за 12 месяцев 2024", "Q4", True),
+ ("1-й квартал 2024", "Q1", True),
+ ("2-й квартал 2024", "Q2", False),
+ ("1 кв. 2024", "Q1", True),
+ ("Январь - Июнь 2024 г.", "Q2", True),
+ ("01.01.2025 - 31.03.2025", "Q1", True),
+ ("01.01.2025 - 30.06.2025", "Q2", True),
+ ("01.01.2025 - 30.09.2025", "Q3", True),
+ ("01.01.2025 - 31.12.2025", "Q4", True),
+ ("за 1 полугодие 2025 г.", "Q2", True),
+ ],
+)
+def test_period_real_and_variant_phrases(rules: Rules, text: str, quarter: str, cumulative: bool) -> None:
+ rule = rules.periods.match(text)
+
+ assert rule is not None, text
+ assert (rule.quarter, rule.is_cumulative) == (quarter, cumulative)
+
+
+def test_period_rules_are_ordered_specific_before_general(rules: Rules) -> None:
+ # «12 месяцев» и «9 месяцев» не должны ловиться более общими правилами (например, «2 месяца»-подобными)
+ assert rules.periods.match("12 месяцев").quarter == "Q4" # type: ignore[union-attr]
+ assert rules.periods.match("9 месяцев").quarter == "Q3" # type: ignore[union-attr]
+ assert rules.periods.match("1 полугодие 2024 г.").quarter == "Q2" # type: ignore[union-attr]
+
+
+def test_unknown_period_text_gives_none(rules: Rules) -> None:
+ assert rules.periods.match("за неделю") is None
+ assert rules.periods.match("") is None
+
+
+@pytest.mark.parametrize(
+ ("text", "year"),
+ [("за 1 квартал 2025 г.", "2025"), ("2024 г.", "2024"), ("01.01.2023 - 31.03.2023", "2023")],
+)
+def test_period_year_pattern(rules: Rules, text: str, year: str) -> None:
+ match = rules.periods.year.search(text)
+
+ assert match is not None
+ assert match.group(0) == year
+
+
+def test_period_year_pattern_ignores_numbers_that_are_not_years(rules: Rules) -> None:
+ assert rules.periods.year.search("счет 60.01 за 1 квартал") is None
+ assert rules.periods.year.search("ИНН 3528286057") is None
+
+
+# --- Типы данных (§1.3) ---
+
+
+@pytest.mark.parametrize(
+ ("text", "expected"),
+ [
+ ("БУ (данные бухгалтерского учета)", "BU"),
+ ("НУ (данные налогового учета)", "NU"),
+ ("сумма", "SUM"),
+ ("Сумма (в рублях)", "SUM"),
+ ],
+)
+def test_header_data_type(rules: Rules, text: str, expected: str) -> None:
+ assert first_match(rules.data_types.header, text) == expected
+
+
+def test_header_data_type_unknown(rules: Rules) -> None:
+ assert first_match(rules.data_types.header, "Прочее") is None
+
+
+@pytest.mark.parametrize(
+ ("text", "expected"),
+ [("Вал.", "CURRENCY"), ("вал", "CURRENCY"), ("Кол.", "QUANTITY"), ("Количество", "QUANTITY")],
+)
+def test_row_data_type(rules: Rules, text: str, expected: str) -> None:
+ assert first_match(rules.data_types.row, text) == expected
+
+
+@pytest.mark.parametrize("text", ["Валюта расчетов ООО", "Колесо", "60.01", "АО Русский бисквит"])
+def test_row_data_type_does_not_fire_on_ordinary_rows(rules: Rules, text: str) -> None:
+ assert first_match(rules.data_types.row, text) is None
+
+
+def test_currency_codes_cover_iso_minimum(rules: Rules) -> None:
+ required = {"RUB", "USD", "EUR", "CNY", "KZT", "BYN", "GBP", "CHF", "JPY", "TRY", "AED"}
+
+ assert required <= set(rules.data_types.currency_codes)
+ assert all(len(code) == ISO_CODE_LENGTH and code.isupper() for code in rules.data_types.currency_codes)
+
+
+@pytest.mark.parametrize(
+ ("text", "code"),
+ [("руб.", "RUB"), ("RUB", "RUB"), ("руб", "RUB"), ("Рублей", "RUB"), ("рубли", "RUB"), ("rub", "RUB")],
+)
+def test_currency_aliases_map_synonyms_to_iso_code(rules: Rules, text: str, code: str) -> None:
+ assert rules.data_types.currency_code(text) == code
+
+
+def test_currency_aliases_are_patterns_keyed_by_iso_code(rules: Rules) -> None:
+ assert set(rules.data_types.currency_aliases) <= set(rules.data_types.currency_codes)
+ assert all(isinstance(p, Pattern) for ps in rules.data_types.currency_aliases.values() for p in ps)
+ assert rules.data_types.currency_aliases["RUB"]
+
+
+@pytest.mark.parametrize("text", ["USD", "usd", "EUR", "CNY"])
+def test_currency_code_recognises_bare_iso_code(rules: Rules, text: str) -> None:
+ assert rules.data_types.currency_code(text) == text.upper()
+
+
+@pytest.mark.parametrize("text", ["", "Контрагенты", "ABC", "60.01"])
+def test_currency_code_unknown(rules: Rules, text: str) -> None:
+ assert rules.data_types.currency_code(text) is None
diff --git a/api/tests/unit/test_app.py b/api/tests/unit/test_app.py
index 77e214e..8ee3b01 100644
--- a/api/tests/unit/test_app.py
+++ b/api/tests/unit/test_app.py
@@ -50,7 +50,7 @@ class FakeStorage:
return self.objects[(bucket, key)]
async def put_json(self, bucket: str, key: str, obj: Any) -> None:
- raise AssertionError("NullParser не пишет файл результата")
+ raise AssertionError("байты «xlsx» — не книга: парсер отдаёт ошибку без файла результата")
class FakeNatsConnection:
@@ -170,7 +170,8 @@ async def test_nats_enabled_subscribes_worker_and_reports_ok(
worker = patched_app.app.state.worker
assert isinstance(worker, RecognitionWorker)
- # Запрос, доставленный подписке, проходит через воркер: ack и результат NOT_IMPLEMENTED в recognition.result
+ # Запрос, доставленный подписке, проходит через воркер с парсером по умолчанию (`get_parser(settings)` →
+ # OsvParser): байты «xlsx» — не книга, ack и результат UNSUPPORTED_FORMAT в recognition.result
_subject, handler = connection.handlers[0]
message = SimpleNamespace(
data=json.dumps(REQUEST).encode(), headers={NATS_MSG_ID_HEADER: f"{JOB_ID}:1"}, subject=_subject
@@ -182,7 +183,7 @@ async def test_nats_enabled_subscribes_worker_and_reports_ok(
(RESULT_SUBJECT, f"ack:{JOB_ID}:1"),
(RESULT_SUBJECT, f"result:{JOB_ID}:1"),
]
- assert connection.published[1][1]["error"]["code"] == "NOT_IMPLEMENTED"
+ assert connection.published[1][1]["error"]["code"] == "UNSUPPORTED_FORMAT"
assert connection.published[1][1]["service"]["name"] == "tabular-parser"
# Старт: воркер готов до подключения (on_connected сразу подписывает worker.on_request).
# Остановка: воркер дорабатывает очередь, пока NATS ещё жив, затем NATS, затем S3
diff --git a/api/tests/unit/test_config.py b/api/tests/unit/test_config.py
index cbb420c..0abd8b5 100644
--- a/api/tests/unit/test_config.py
+++ b/api/tests/unit/test_config.py
@@ -23,6 +23,15 @@ def test_minimal_environment_gives_defaults(minimal_settings: SettingsFactory) -
assert settings.WORKER_CONCURRENCY == 1
assert settings.PUBLISH_RETRIES == 3 # noqa: PLR2004 - значение по умолчанию из spec
assert settings.DONE_CACHE_SIZE == 1000 # noqa: PLR2004 - значение по умолчанию из spec
+ assert settings.PARSE_MAX_BYTES == 50 * 1024 * 1024 # design D-8: 50 МиБ
+ assert settings.PARSE_TIMEOUT_SECONDS == 120 # noqa: PLR2004 - значение по умолчанию из design D-8
+
+
+def test_parse_limits_must_be_positive(minimal_settings: SettingsFactory) -> None:
+ with pytest.raises(ValidationError, match="PARSE_MAX_BYTES"):
+ minimal_settings("PARSE_MAX_BYTES=0\n")
+ with pytest.raises(ValidationError, match="PARSE_TIMEOUT_SECONDS"):
+ minimal_settings("PARSE_TIMEOUT_SECONDS=0\n")
def test_publish_retries_zero_is_allowed(minimal_settings: SettingsFactory) -> None:
diff --git a/api/tests/unit/test_contract.py b/api/tests/unit/test_contract.py
index 8aea34c..bce77dc 100644
--- a/api/tests/unit/test_contract.py
+++ b/api/tests/unit/test_contract.py
@@ -1,4 +1,4 @@
-"""Контракт обмена с сервисами распознавания v1.0.
+"""Контракт обмена с сервисами распознавания v1.1.
Спека сервиса — `tabular-recognition-service`; change `recognition-contract` лежит в `../docs`.
Быстрые тесты без БД: вендоренные схемы `api/contracts/` идентичны `../docs/contracts/`; каждый пример проходит и
@@ -56,9 +56,10 @@ EXAMPLE_KIND = {
"result-message-processing.json": "message",
"result-error.json": "result",
"result-osv-minimal.json": "result",
+ "result-osv-needs-check.json": "result",
"result-statement-minimal.json": "result",
}
-EXAMPLES_COUNT = 8
+EXAMPLES_COUNT = 9
def _load(path: Path) -> Any:
@@ -453,6 +454,21 @@ CASES: list[tuple[str, str, Callable[[dict[str, Any]], None], bool]] = [
False,
),
("result/osv-extra-field", "result-osv-minimal.json", _set("documents.0.debug", {"a": 1}), True),
+ (
+ "result/osv-row-extra-strings",
+ "result-osv-minimal.json",
+ _set("documents.0.rows.0.extra", {"КПП": "1", "Н": None}),
+ True,
+ ),
+ ("result/osv-row-extra-null", "result-osv-minimal.json", _set("documents.0.rows.0.extra", None), True),
+ ("result/osv-row-extra-absent", "result-osv-minimal.json", _drop("documents.0.rows.1.extra"), True),
+ (
+ "result/osv-row-extra-non-string-value",
+ "result-osv-minimal.json",
+ _set("documents.0.rows.0.extra", {"КПП": 1}),
+ False,
+ ),
+ ("result/osv-row-extra-not-object", "result-osv-minimal.json", _set("documents.0.rows.0.extra", ["КПП"]), False),
("result/statement-no-lines", "result-statement-minimal.json", _drop("documents.0.lines"), False),
("result/statement-no-columns", "result-statement-minimal.json", _drop("documents.0.columns"), False),
(
@@ -588,7 +604,7 @@ def test_message_status_enum_values() -> None:
def test_contract_version_constant() -> None:
- assert CONTRACT_VERSION == "1.0"
+ assert CONTRACT_VERSION == "1.1"
def test_python_float_amount_is_rejected_with_clear_error() -> None:
diff --git a/api/tests/unit/test_fixtures_helpers.py b/api/tests/unit/test_fixtures_helpers.py
new file mode 100644
index 0000000..ec30268
--- /dev/null
+++ b/api/tests/unit/test_fixtures_helpers.py
@@ -0,0 +1,103 @@
+"""Хелперы доступа к ../fixtures: каталог из ARM_KA_FIXTURES, список файлов ОСВ без синтетики, причина skip."""
+
+from pathlib import Path
+
+import pytest
+from fixtures_helpers import (
+ FIXTURES_ENV,
+ fixtures_skip_reason,
+ osv_fixture_files,
+ resolve_fixtures_dir,
+ synthetic_files,
+ synthetic_skip_reason,
+)
+
+
+def test_env_overrides_fixtures_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv(FIXTURES_ENV, str(tmp_path))
+ assert resolve_fixtures_dir() == tmp_path
+
+
+def test_default_fixtures_dir_is_sibling_of_repository(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv(FIXTURES_ENV, raising=False)
+ assert resolve_fixtures_dir() == Path(__file__).resolve().parents[4] / "fixtures"
+
+
+def test_no_osv_directory_gives_no_files(tmp_path: Path) -> None:
+ assert osv_fixture_files(tmp_path) == []
+ assert synthetic_files(tmp_path) == []
+
+
+def test_synthetic_excluded_and_foreign_files_ignored(tmp_path: Path) -> None:
+ osv = tmp_path / "osv"
+ (osv / "rb").mkdir(parents=True)
+ (osv / "synthetic").mkdir()
+ (osv / "rb" / "b.xlsx").write_bytes(b"")
+ (osv / "a.xls").write_bytes(b"")
+ (osv / "c.xlsb").write_bytes(b"")
+ (osv / ".DS_Store").write_bytes(b"")
+ (osv / "notes.txt").write_bytes(b"")
+ (osv / "synthetic" / "s.xlsx").write_bytes(b"")
+ (osv / "synthetic" / ".gitkeep").write_bytes(b"")
+ (osv / "synthetic" / "README.md").write_bytes(b"")
+
+ assert osv_fixture_files(tmp_path) == [osv / "a.xls", osv / "c.xlsb", osv / "rb" / "b.xlsx"]
+ assert synthetic_files(tmp_path) == [osv / "synthetic" / "s.xlsx"]
+
+
+def test_hidden_and_excel_lock_files_are_ignored(tmp_path: Path) -> None:
+ """T1.1: скрытые файлы и каталоги и lock-файлы Excel (`~$…`, пока книга открыта) — не фикстуры."""
+ osv = tmp_path / "osv"
+ (osv / "rb" / ".cache").mkdir(parents=True)
+ (osv / "synthetic").mkdir()
+ for path in (
+ osv / "rb" / "b.xlsx",
+ osv / "rb" / "~$b.xlsx",
+ osv / "rb" / ".b.xlsx",
+ osv / "rb" / ".cache" / "c.xlsx",
+ osv / "synthetic" / "s.xlsx",
+ osv / "synthetic" / "~$s.xlsx",
+ osv / "synthetic" / ".s.xls",
+ ):
+ path.write_bytes(b"")
+
+ assert osv_fixture_files(tmp_path) == [osv / "rb" / "b.xlsx"]
+ assert synthetic_files(tmp_path) == [osv / "synthetic" / "s.xlsx"]
+
+
+def test_skip_reason_mentions_env_and_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ missing = tmp_path / "нет-такого"
+ monkeypatch.setenv(FIXTURES_ENV, str(missing))
+ reason = fixtures_skip_reason()
+ assert reason is not None
+ assert FIXTURES_ENV in reason
+ assert str(missing) in reason
+
+
+def test_skip_reason_none_when_available(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ (tmp_path / "osv").mkdir()
+ monkeypatch.setenv(FIXTURES_ENV, str(tmp_path))
+ assert fixtures_skip_reason() is None
+
+
+def test_synthetic_skip_reason_when_fixtures_exist_but_synthetic_is_missing_or_empty(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ (tmp_path / "osv").mkdir()
+ monkeypatch.setenv(FIXTURES_ENV, str(tmp_path))
+ reason = synthetic_skip_reason()
+ assert reason is not None
+ assert "osv" in reason
+ assert "synthetic" in reason
+
+ (tmp_path / "osv" / "synthetic").mkdir()
+ (tmp_path / "osv" / "synthetic" / "README.md").write_bytes(b"")
+ assert synthetic_skip_reason() == reason
+
+ (tmp_path / "osv" / "synthetic" / "s01.xlsx").write_bytes(b"")
+ assert synthetic_skip_reason() is None
+
+
+def test_synthetic_skip_reason_is_none_without_fixtures(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv(FIXTURES_ENV, str(tmp_path / "нет"))
+ assert synthetic_skip_reason() is None # причину тогда даёт fixtures_skip_reason
diff --git a/api/tests/unit/test_golden_helpers.py b/api/tests/unit/test_golden_helpers.py
new file mode 100644
index 0000000..0f0c4ab
--- /dev/null
+++ b/api/tests/unit/test_golden_helpers.py
@@ -0,0 +1,77 @@
+"""Помощники golden-снимков: нормализация volatile-полей, путь снимка, формат записи (design D-10)."""
+
+import json
+from pathlib import Path
+
+from golden_helpers import SNAPSHOT_DIR, golden_path, normalize_result, snapshot_text, unified_diff
+
+
+def _result() -> dict:
+ return {
+ "schema_version": "1.1",
+ "job_id": "00000000-0000-0000-0000-000000000000",
+ "file_id": "00000000-0000-0000-0000-000000000000",
+ "service": {"name": "tabular-parser", "version": "0.1.0"},
+ "status": "done",
+ "metrics": {"duration_ms": 12, "rows": 7, "warnings": ["б", "а"]},
+ "documents": [{"z": 1, "a": 2}],
+ }
+
+
+def test_normalize_removes_exactly_the_volatile_fields() -> None:
+ normalized = normalize_result(_result())
+
+ assert "job_id" not in normalized
+ assert "file_id" not in normalized
+ assert normalized["service"] == {"name": "tabular-parser"}
+ assert normalized["metrics"] == {"rows": 7, "warnings": ["б", "а"]}
+ assert normalized["documents"] == [{"z": 1, "a": 2}]
+
+
+def test_normalize_keeps_key_and_warning_order_and_does_not_mutate_input() -> None:
+ source = _result()
+
+ normalized = normalize_result(source)
+
+ assert list(normalized) == ["schema_version", "service", "status", "metrics", "documents"]
+ assert list(normalized["documents"][0]) == ["z", "a"]
+ assert normalized["metrics"]["warnings"] == ["б", "а"]
+ assert source == _result()
+
+
+def test_normalize_tolerates_missing_volatile_fields() -> None:
+ assert normalize_result({"status": "error"}) == {"status": "error"}
+
+
+def test_golden_path_mirrors_osv_tree_without_extension(tmp_path: Path) -> None:
+ root = tmp_path
+ file = root / "osv" / "rb" / "2025-Q1" / "ОСВ сч 60.xlsx"
+
+ assert golden_path(root, file) == root / "golden" / SNAPSHOT_DIR / "rb" / "2025-Q1" / "ОСВ сч 60.json"
+ synthetic = root / "osv" / "synthetic" / "s01.xlsx"
+ assert golden_path(root, synthetic) == root / "golden" / SNAPSHOT_DIR / "synthetic" / "s01.json"
+
+
+def test_golden_path_keeps_dots_inside_the_name(tmp_path: Path) -> None:
+ file = tmp_path / "osv" / "a" / "сч 62 за 3 квартал 2024 г..xls"
+
+ assert golden_path(tmp_path, file).name == "сч 62 за 3 квартал 2024 г..json"
+
+
+def test_snapshot_text_is_utf8_json_with_indent_and_trailing_newline() -> None:
+ text = snapshot_text({"имя": "ОСВ", "n": ["1"]})
+
+ assert text.endswith("}\n")
+ assert "ОСВ" in text
+ assert json.loads(text) == {"имя": "ОСВ", "n": ["1"]}
+ assert text.startswith('{\n "имя"')
+
+
+def test_unified_diff_names_files_and_shows_changed_lines() -> None:
+ diff = unified_diff("a\nb\n", "a\nc\n", name="снимок.json")
+
+ assert "--- снимок.json (в fixtures)" in diff
+ assert "+++ снимок.json (разбор сейчас)" in diff
+ assert "-b" in diff
+ assert "+c" in diff
+ assert unified_diff("a\n", "a\n", name="x") == ""
diff --git a/api/tests/unit/test_golden_sync.py b/api/tests/unit/test_golden_sync.py
new file mode 100644
index 0000000..2f8595f
--- /dev/null
+++ b/api/tests/unit/test_golden_sync.py
@@ -0,0 +1,179 @@
+"""`sync_snapshots` и `scripts/golden_update.py`: запись, сравнение, diff, лишние снимки (на временном каталоге)."""
+
+import shutil
+import sys
+from pathlib import Path
+
+import pytest
+from golden_helpers import check_snapshot, golden_path, render, snapshot_text, sync_snapshots
+
+from src.parser import get_parser
+from tests.synthetic.generate import generate_all
+
+FILES = 2 # s01 и s02
+API_ROOT = Path(__file__).resolve().parents[2]
+
+
+@pytest.fixture(scope="module")
+def fixtures_root(tmp_path_factory: pytest.TempPathFactory) -> Path:
+ root = tmp_path_factory.mktemp("fixtures")
+ generate_all(root / "osv" / "synthetic")
+ return root
+
+
+@pytest.fixture
+def root(fixtures_root: Path, tmp_path: Path) -> Path:
+ """Копия дерева `osv/` на каждый тест: снимки не пересекаются между тестами."""
+ copy = tmp_path / "fx"
+ (copy / "osv" / "synthetic").mkdir(parents=True)
+ for source in sorted((fixtures_root / "osv" / "synthetic").glob("s0[12]*")):
+ (copy / "osv" / "synthetic" / source.name).write_bytes(source.read_bytes())
+ return copy
+
+
+def files_of(root: Path) -> list[Path]:
+ return sorted((root / "osv" / "synthetic").glob("s*"))
+
+
+def test_first_run_creates_snapshots_equal_to_render(root: Path) -> None:
+ parser = get_parser()
+
+ report = sync_snapshots(root, files_of(root), parser, write=True)
+
+ assert len(report.created) == FILES
+ assert report.changed == [] and report.orphans == [] and report.unchanged == 0
+ for file in files_of(root):
+ assert golden_path(root, file).read_text(encoding="utf-8") == snapshot_text(render(parser, file))
+ assert (root / "golden" / "expected_osv_parse" / "synthetic" / "s01_variant2_header.json").is_file()
+
+
+def test_second_run_reports_nothing_and_check_does_not_write(root: Path) -> None:
+ parser = get_parser()
+ sync_snapshots(root, files_of(root), parser, write=True)
+
+ again = sync_snapshots(root, files_of(root), parser, write=False)
+
+ assert again.unchanged == FILES
+ assert not again.differs
+ assert again.diffs == []
+
+
+def test_changed_snapshot_gives_diff_and_check_leaves_it_untouched(root: Path) -> None:
+ parser = get_parser()
+ sync_snapshots(root, files_of(root), parser, write=True)
+ target = golden_path(root, files_of(root)[0])
+ original = target.read_text(encoding="utf-8")
+ target.write_text(original.replace('"done"', '"needs_check"', 1), encoding="utf-8")
+
+ checked = sync_snapshots(root, files_of(root), parser, write=False)
+
+ assert checked.changed == [target.relative_to(root)]
+ assert '- "status": "needs_check"' in checked.diffs[0]
+ assert '+ "status": "done"' in checked.diffs[0]
+ assert target.read_text(encoding="utf-8") != original
+
+ written = sync_snapshots(root, files_of(root), parser, write=True)
+
+ assert written.changed == [target.relative_to(root)]
+ assert target.read_text(encoding="utf-8") == original
+
+
+def test_orphan_snapshot_is_reported_and_removed_on_write(root: Path) -> None:
+ parser = get_parser()
+ sync_snapshots(root, files_of(root), parser, write=True)
+ orphan = root / "golden" / "expected_osv_parse" / "synthetic" / "s99_gone.json"
+ orphan.write_text("{}\n", encoding="utf-8")
+
+ checked = sync_snapshots(root, files_of(root), parser, write=False)
+ assert checked.orphans == [orphan.relative_to(root)]
+ assert orphan.exists()
+
+ sync_snapshots(root, files_of(root), parser, write=True)
+ assert not orphan.exists()
+
+
+def test_snapshots_are_kept_when_their_source_directory_is_absent(root: Path) -> None:
+ """T12.3: без `osv/synthetic` (или без реальных файлов) снимки этой части — не «лишние»: запись их не удаляет,
+ сверка о них не сообщает как о расхождении, но перечисляет как сохранённые."""
+ parser = get_parser()
+ sync_snapshots(root, files_of(root), parser, write=True)
+ synthetic_snapshots = sorted((root / "golden" / "expected_osv_parse" / "synthetic").glob("*.json"))
+ real_snapshot = root / "golden" / "expected_osv_parse" / "rb" / "2024-Q1" / "сч 60.json"
+ real_snapshot.parent.mkdir(parents=True)
+ real_snapshot.write_text("{}\n", encoding="utf-8")
+ shutil.rmtree(root / "osv" / "synthetic")
+
+ report = sync_snapshots(root, [], parser, write=True)
+
+ assert report.orphans == []
+ assert not report.differs
+ assert report.protected == sorted(
+ [*(path.relative_to(root) for path in synthetic_snapshots), real_snapshot.relative_to(root)]
+ )
+ assert all(path.is_file() for path in synthetic_snapshots)
+ assert real_snapshot.is_file()
+
+
+def test_orphan_is_removed_only_within_a_present_source_directory(root: Path) -> None:
+ """Реальных файлов нет, синтетика есть: лишний синтетический снимок удаляется, реальные снимки сохраняются."""
+ parser = get_parser()
+ real_snapshot = root / "golden" / "expected_osv_parse" / "rb" / "сч 60.json"
+ real_snapshot.parent.mkdir(parents=True)
+ real_snapshot.write_text("{}\n", encoding="utf-8")
+ orphan = root / "golden" / "expected_osv_parse" / "synthetic" / "s99_gone.json"
+ orphan.parent.mkdir(parents=True)
+ orphan.write_text("{}\n", encoding="utf-8")
+
+ report = sync_snapshots(root, files_of(root), parser, write=True)
+
+ assert report.orphans == [orphan.relative_to(root)]
+ assert report.protected == [real_snapshot.relative_to(root)]
+ assert not orphan.exists()
+ assert real_snapshot.is_file()
+
+
+def test_golden_test_fails_with_hint_when_snapshot_is_missing(root: Path) -> None:
+ """Spec «Новый файл без снимка»: golden-тест сообщает «нет снимка: выполните make golden-update»."""
+ file = files_of(root)[0]
+
+ with pytest.raises(pytest.fail.Exception, match=r"нет снимка: выполните make golden-update"):
+ check_snapshot(root, file, get_parser())
+
+
+def test_golden_test_fails_with_diff_when_parse_result_changed(root: Path) -> None:
+ """Spec «Регрессия»: расхождение разбора со снимком — падение с diff по этому файлу."""
+ parser = get_parser()
+ sync_snapshots(root, files_of(root), parser, write=True)
+ file = files_of(root)[0]
+ check_snapshot(root, file, parser) # свежий снимок совпадает с разбором
+ target = golden_path(root, file)
+ target.write_text(target.read_text(encoding="utf-8").replace('"level": 1', '"level": 7', 1), encoding="utf-8")
+
+ with pytest.raises(pytest.fail.Exception, match=r"отличается от снимка") as failure:
+ check_snapshot(root, file, parser)
+
+ diff_lines = str(failure.value).splitlines()
+ assert any(line.startswith("-") and '"level": 7' in line for line in diff_lines)
+ assert any(line.startswith("+") and '"level": 1' in line for line in diff_lines)
+
+
+def test_script_check_exit_codes(
+ root: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
+) -> None:
+ monkeypatch.setenv("ARM_KA_FIXTURES", str(root))
+ monkeypatch.syspath_prepend(str(API_ROOT / "scripts"))
+ sys.modules.pop("golden_update", None)
+ import golden_update # noqa: PLC0415 - модуль-скрипт
+
+ assert golden_update.main(["--check"]) == 1
+ assert golden_update.main([]) == 0
+ assert golden_update.main(["--check"]) == 0
+ shutil.rmtree(root / "osv" / "synthetic")
+ capsys.readouterr()
+ assert golden_update.main([]) == 0
+ assert "снимок сохранён (нет исходного каталога)" in capsys.readouterr().err
+ assert (root / "golden" / "expected_osv_parse" / "synthetic" / "s01_variant2_header.json").is_file()
+ monkeypatch.setenv("ARM_KA_FIXTURES", str(root / "нет"))
+ capsys.readouterr()
+ assert golden_update.main([]) == golden_update.EXIT_NO_FIXTURES
+ assert "каталог фикстур не найден" in capsys.readouterr().err
diff --git a/api/tests/unit/test_null_parser.py b/api/tests/unit/test_null_parser.py
deleted file mode 100644
index 22aec10..0000000
--- a/api/tests/unit/test_null_parser.py
+++ /dev/null
@@ -1,49 +0,0 @@
-"""Парсер-заглушка Среза 0 (design D-3): `NOT_IMPLEMENTED` для любого типа файла, входные байты не читаются."""
-
-from typing import Any, get_args
-
-import pytest
-
-from src.contract import ErrorCode, FileType, MessageError, ResultStatus
-from src.parser import NullParser, ParseOutcome, Parser, get_parser
-
-
-class UntouchableBytes(bytes):
- """Байты, любое обращение к содержимому которых — ошибка теста."""
-
- def _fail(self, *_args: Any, **_kwargs: Any) -> Any:
- raise AssertionError("NullParser прикоснулся к входным байтам")
-
- __iter__ = __len__ = __getitem__ = __bytes__ = __contains__ = __eq__ = __hash__ = _fail # type: ignore[assignment]
-
-
-@pytest.mark.parametrize("file_type", get_args(FileType))
-def test_null_parser_returns_not_implemented_without_touching_input(file_type: FileType) -> None:
- data = UntouchableBytes(b"content")
-
- outcome = NullParser().parse(data, file_type)
-
- assert outcome.status is ResultStatus.ERROR
- assert outcome.error == MessageError(
- code=ErrorCode.NOT_IMPLEMENTED,
- message="разбор XLS/XLSX/XLSB реализуется в Срезе 2",
- )
- assert outcome.documents == ()
- assert outcome.metrics is None
-
-
-def test_get_parser_returns_null_parser() -> None:
- parser = get_parser()
-
- assert isinstance(parser, NullParser)
- assert isinstance(parser, Parser)
-
-
-def test_outcome_error_requires_error_payload() -> None:
- with pytest.raises(ValueError, match="error"):
- ParseOutcome(status=ResultStatus.ERROR)
-
-
-def test_outcome_success_requires_documents() -> None:
- with pytest.raises(ValueError, match="documents"):
- ParseOutcome(status=ResultStatus.DONE)
diff --git a/api/tests/unit/test_parser_factory.py b/api/tests/unit/test_parser_factory.py
new file mode 100644
index 0000000..93c4d77
--- /dev/null
+++ b/api/tests/unit/test_parser_factory.py
@@ -0,0 +1,84 @@
+"""Фабрика парсера и заглушка (design D-3, D-7, D-8; spec «Парсер — заменяемый интерфейс», «Словари — данные»).
+
+`get_parser()` по умолчанию отдаёт `OsvParser` со словарями `api/rules/` и лимитами из настроек; битый словарь —
+падение при создании (старт сервиса), а не при первом файле. `NullParser` остаётся для тестов воркера:
+`NOT_IMPLEMENTED` для любого типа файла, входные байты не читаются.
+"""
+
+import shutil
+from collections.abc import Callable
+from pathlib import Path
+from typing import Any, get_args
+
+import pytest
+
+from src.contract import ErrorCode, FileType, MessageError, ResultStatus
+from src.core.config import Settings
+from src.parser import NullParser, ParseOutcome, Parser, get_parser
+from src.parser.osv import OsvParser
+from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE
+
+DEFAULT_MAX_BYTES = 50 * 1024 * 1024
+DEFAULT_TIMEOUT_SECONDS = 120
+
+
+class UntouchableBytes(bytes):
+ """Байты, любое обращение к содержимому которых — ошибка теста."""
+
+ def _fail(self, *_args: Any, **_kwargs: Any) -> Any:
+ raise AssertionError("NullParser прикоснулся к входным байтам")
+
+ __iter__ = __len__ = __getitem__ = __bytes__ = __contains__ = __eq__ = __hash__ = _fail # type: ignore[assignment]
+
+
+@pytest.mark.parametrize("file_type", get_args(FileType))
+def test_null_parser_returns_not_implemented_without_touching_input(file_type: FileType) -> None:
+ data = UntouchableBytes(b"content")
+
+ outcome = NullParser().parse(data, file_type)
+
+ assert outcome.status is ResultStatus.ERROR
+ assert outcome.error == MessageError(
+ code=ErrorCode.NOT_IMPLEMENTED,
+ message="разбор XLS/XLSX/XLSB реализуется в Срезе 2",
+ )
+ assert outcome.documents == ()
+ assert outcome.metrics is None
+
+
+def test_get_parser_returns_osv_parser_with_default_limits() -> None:
+ parser = get_parser()
+
+ assert isinstance(parser, OsvParser)
+ assert isinstance(parser, Parser)
+ assert (parser.max_bytes, parser.timeout_seconds) == (DEFAULT_MAX_BYTES, DEFAULT_TIMEOUT_SECONDS)
+
+
+def test_get_parser_takes_limits_from_settings(minimal_settings: Callable[..., Settings]) -> None:
+ settings = minimal_settings("PARSE_MAX_BYTES=1024\nPARSE_TIMEOUT_SECONDS=7\n")
+
+ parser = get_parser(settings)
+
+ assert isinstance(parser, OsvParser)
+ assert (parser.max_bytes, parser.timeout_seconds) == (1024, 7) # noqa: PLR2004 - значения из окружения теста
+
+
+def test_get_parser_fails_on_broken_dictionary(tmp_path: Path) -> None:
+ """Spec «Словари — данные»: ошибка словаря — падение старта с указанием файла и записи."""
+ broken = tmp_path / "rules"
+ shutil.copytree(DEFAULT_RULES_DIR, broken)
+ headers = broken / HEADERS_FILE
+ headers.write_text(headers.read_text(encoding="utf-8").replace("total_row:", "total_rows:"), encoding="utf-8")
+
+ with pytest.raises(RuntimeError, match=r"osv_headers\.yaml.*total_row"):
+ get_parser(rules_dir=broken)
+
+
+def test_outcome_error_requires_error_payload() -> None:
+ with pytest.raises(ValueError, match="error"):
+ ParseOutcome(status=ResultStatus.ERROR)
+
+
+def test_outcome_success_requires_documents() -> None:
+ with pytest.raises(ValueError, match="documents"):
+ ParseOutcome(status=ResultStatus.DONE)
diff --git a/api/tests/unit/test_rules_coverage_helpers.py b/api/tests/unit/test_rules_coverage_helpers.py
new file mode 100644
index 0000000..f11c1b5
--- /dev/null
+++ b/api/tests/unit/test_rules_coverage_helpers.py
@@ -0,0 +1,75 @@
+"""Механизм покрытия словарей (R-22) без каталога fixtures: обход записей, счётчик срабатываний, сообщение о мёртвых."""
+
+import shutil
+from pathlib import Path
+
+import pytest
+from rules_coverage import DICTIONARIES, MatchCounter, iter_patterns, uncovered
+
+from src.parser.rules import DEFAULT_RULES_DIR, Pattern, load_rules
+
+
+def test_every_pattern_of_every_dictionary_is_enumerated_with_file_and_key() -> None:
+ rules = load_rules()
+
+ entries = list(iter_patterns(rules))
+
+ assert {file_name for file_name, _, _ in entries} == set(DICTIONARIES.values())
+ keys = {(file_name, path) for file_name, path, _ in entries}
+ assert ("osv_headers.yaml", "sheet_markers[0]") in keys
+ assert ("osv_headers.yaml", "groups.opening[0]") in keys
+ assert ("osv_groups.yaml", "mapping[0][0]") in keys
+ assert ("periods.yaml", "rules[0].pattern") in keys
+ assert ("periods.yaml", "year") in keys
+ assert ("data_types.yaml", "currency_aliases.RUB[0]") in keys
+ # R-22: перечень кодов ISO — значения, не синонимы
+ assert not any("currency_codes" in path for _, path, _ in entries)
+ assert all(isinstance(pattern, Pattern) for _, _, pattern in entries)
+
+
+def test_counter_counts_matches_of_search_and_search_normalized_only(monkeypatch: pytest.MonkeyPatch) -> None:
+ rules = load_rules()
+ counter = MatchCounter()
+ counter.install(monkeypatch)
+ total = rules.headers.total_row[0]
+ inn = rules.headers.service_columns["inn"][0]
+
+ assert total.search("Итого") is not None
+ assert inn.search_normalized("инн") is not None
+ assert inn.search_normalized("нет такого") is None
+
+ dead = uncovered(rules, counter)
+ assert not any("total_row[0]" in line or "service_columns.inn[0]" in line for line in dead)
+ assert any("sheet_markers[0]" in line for line in dead)
+
+
+def test_message_lists_file_key_and_pattern_of_a_dead_entry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ rules_dir = tmp_path / "rules"
+ shutil.copytree(DEFAULT_RULES_DIR, rules_dir)
+ headers = rules_dir / "osv_headers.yaml"
+ headers.write_text(
+ headers.read_text(encoding="utf-8").replace(
+ "total_row:\n - '^итого\\b'", "total_row:\n - '^итого\\b'\n - '^никогда не встречается$'"
+ ),
+ encoding="utf-8",
+ )
+ rules = load_rules(rules_dir)
+ counter = MatchCounter()
+ counter.install(monkeypatch)
+ assert rules.headers.total_row[0].search("Итого по счету") is not None
+ assert rules.headers.total_row[1].search("Итого по счету") is None
+
+ dead = uncovered(rules, counter)
+
+ assert "osv_headers.yaml: total_row[1]: '^никогда не встречается$'" in dead
+ assert not any("total_row[0]" in line for line in dead)
+
+
+def test_counter_does_not_change_matching_results(monkeypatch: pytest.MonkeyPatch) -> None:
+ pattern = Pattern.compile(r"\bбу\b")
+ before = [pattern.search("данные БУ"), pattern.search("нет")]
+ MatchCounter().install(monkeypatch)
+
+ after = [pattern.search("данные БУ"), pattern.search("нет")]
+
+ assert [m.group(0) if m else None for m in after] == [m.group(0) if m else None for m in before]
diff --git a/api/tests/unit/test_smoke_fixtures.py b/api/tests/unit/test_smoke_fixtures.py
new file mode 100644
index 0000000..c3f72c3
--- /dev/null
+++ b/api/tests/unit/test_smoke_fixtures.py
@@ -0,0 +1,10 @@
+"""Дымовой тест доступа к реальным ОСВ: параметризация `osv_file` из conftest видит все файлы."""
+
+from pathlib import Path
+
+import pytest
+
+
+@pytest.mark.fixtures
+def test_smoke_fixtures(osv_file: Path) -> None:
+ assert osv_file.exists()
diff --git a/api/tests/unit/test_worker.py b/api/tests/unit/test_worker.py
index 8035b00..8d43762 100644
--- a/api/tests/unit/test_worker.py
+++ b/api/tests/unit/test_worker.py
@@ -33,7 +33,7 @@ from src.contract import (
from src.core.config import NATSSettings
from src.core.nats import NatsConnection, NatsPublishError
from src.core.worker import RecognitionWorker, result_key
-from src.parser import NullParser, ParseOutcome
+from src.parser import NullParser, ParseOutcome, get_parser
API_DIR = Path(__file__).resolve().parents[2]
CONTRACTS_DIR = API_DIR / "contracts"
@@ -231,7 +231,7 @@ async def test_ack_is_published_before_s3_read_and_result(env: Env) -> None:
assert subjects == {RESULT_SUBJECT}
ack = env.bodies()[0]
assert ack == {
- "schema_version": "1.0",
+ "schema_version": "1.1",
"job_id": JOB_ID,
"file_id": FILE_ID,
"status": "processing",
@@ -368,7 +368,7 @@ async def test_unsupported_major_version_gives_schema_mismatch_without_ack(env:
result = env.bodies()[0]
_assert_message_valid(result)
assert result["status"] == "error"
- assert result["schema_version"] == "1.0"
+ assert result["schema_version"] == "1.1"
assert result["job_id"] == JOB_ID
assert result["file_id"] == FILE_ID
assert result["error"]["code"] == "SCHEMA_MISMATCH"
@@ -503,7 +503,7 @@ async def test_substituted_parser_gives_done_with_result_file() -> None:
_assert_message_valid(envelope)
assert envelope["status"] == "done"
assert envelope["result_s3"] == {"bucket": BUCKET, "key": RESULT_KEY}
- assert envelope["result_schema_version"] == "1.0"
+ assert envelope["result_schema_version"] == "1.1"
assert "error" not in envelope
assert envelope["metrics"]["rows"] == PARSER_ROWS
assert envelope["metrics"]["duration_ms"] >= 0
@@ -825,3 +825,32 @@ async def test_malformed_request_for_done_job_is_dropped(env: Env) -> None:
await env.worker.join()
assert env.msg_ids() == [f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1"]
+
+
+# --- реальный парсер в воркере ------------------------------------------------------------------------------------
+REAL_OSV_66 = Path("osv") / "rb" / "2024-FY" / "ОСВ_66 за 2024.xls"
+
+
+@pytest.mark.fixtures
+async def test_worker_with_real_osv_parser_gives_done_and_valid_result_file(fixtures_dir: Path) -> None:
+ """Spec «Реальная ОСВ через воркер»: настоящий `OsvParser` (`get_parser`) + байты реальной ОСВ 66 без NATS/S3."""
+ environment = Env(get_parser())
+ environment.storage.objects[(BUCKET, SOURCE_KEY)] = (fixtures_dir / REAL_OSV_66).read_bytes()
+ environment.worker.start()
+ try:
+ await environment.worker.on_request(_msg(_request(file_type="XLS")))
+ await environment.worker.join()
+ finally:
+ await environment.worker.stop(timeout=1)
+
+ assert environment.msg_ids() == [f"ack:{JOB_ID}:1", f"result:{JOB_ID}:1"]
+ envelope = environment.bodies()[1]
+ _assert_message_valid(envelope)
+ assert envelope["status"] == "done"
+ assert envelope["result_schema_version"] == "1.1"
+ assert envelope["result_s3"] == {"bucket": BUCKET, "key": RESULT_KEY}
+ stored = environment.storage.json_objects[(BUCKET, RESULT_KEY)]
+ assert [error.message for error in VALIDATORS["result"].iter_errors(stored)] == []
+ assert RecognitionResult.model_validate(stored).to_wire() == stored
+ assert [document["document_type"] for document in stored["documents"]] == ["OSV"]
+ assert stored["documents"][0]["header"]["account_code_raw"] == "66"
diff --git a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser-ledger.md b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser-ledger.md
new file mode 100644
index 0000000..f871a32
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser-ledger.md
@@ -0,0 +1,138 @@
+# SDD ledger — plan: docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md
+
+Branch feat/5-slice-2a-osv-parser, BASE_BRANCH_START b34e113. Spec: openspec/changes/slice-2a-osv-parser/ (reachable). Neighbour worktrees: ../docs, ../fixtures.
+Maркировка: T. task-minor, C/I/M-n final review, R-n rulings (design #m), O-n observations. Status: fixed | deferred | rejected .
+
+## Pre-flight scan (tasks x shared files/interfaces)
+
+| Pair / task | Produces vs consumes | Finding |
+|---|---|---|
+| T0 vs T2/T9 | T0: CONTRACT_VERSION 1.1, OsvRow.extra; T2/T9 build OsvDocument/rows with extra | OK |
+| T0 vs spec | schema extra optional null; spec D-9 same | OK |
+| T1 vs T2..T13 | T1: conftest params `osv_file`/`synthetic_file`, fixtures_helpers, mypy strict for src.parser | OK. Note `../fixtures/osv` has 66 real files (+1 stray non-xls?) — helper filters by extension |
+| T2 vs T4/T5/T8 | Sheet/Cell API (`text_at`, `row_cells`, `outline`) consumed by detectors | OK |
+| T2 vs T9 | read_book returns (sheets, warnings); spec D-1/tasks 1.3 say tuple[Sheet,...] | plan is more detailed, not contradictory (spec wins on semantics only) -> OK |
+| T2 test claim | "xls 62 за 3 кв: outline строки 15 (1-based) = 3" unverified | R-10: implementer verifies on real file, adjusts number if wrong (not a contract fact) |
+| T3 vs T4/T5/T8/T9 | Rules classes (OsvHeaderRules.groups etc.) | T5 needs RUB aliases that T3 interface lacks (hardcoded "RUB" would be a literal) -> R-6 |
+| T3 vs T12 linter | Linter bans literals `^[A-Z]{1,3}\d{0,7}$` len<=4 and any literal containing «лист» | CONFLICT: contract enum values (BU, NU, Q1.., RUB, XLS) and error messages ("листов просмотрено N", "лист ОСВ без заголовка", D-6 mandated) trip it -> R-4, R-5 |
+| T4 vs T5 | TableHeader.group_label_rows, text_col, inn_col consumed by build_group_schema | OK |
+| T4 vs T8 | ColumnMap.collapsed (single col in *_debit) vs BodyRow.sums (six values) | GAP: nobody splits the collapsed balance by sign nor emits the spec'd warning (spec scenario "свёрнутое сальдо") -> R-1 |
+| T5 vs T9 | DocHeader -> OsvHeader/OsvGroup; DocHeader.warnings to metrics | OK |
+| T6 vs T8 | BodyRow(is_account, account_rank) is built by classify.collect_body_rows using ACCOUNT_RE (T8) | T6 real-file test (all 66 outline) needs collect_body_rows, which does not exist until T8 -> R-2 |
+| T6 vs T9 | resolve_levels(…, deadline: Deadline); Deadline created in T9 (deadline.py) | CONFLICT (T6 earlier than T9) -> R-3 |
+| T7 vs T6/T8 | to_money used in BodyRow.sums (T8) | T7 after T6 is fine since T6 unit tests pass Decimals directly |
+| T8 vs T9 | ClassifiedRow consumed by checks.py/osv.py; extra, TOTAL | OK |
+| T9 vs T10 | get_parser(settings), app.py passes settings; T10 says "app.py уже" | OK |
+| T9 vs spec scenario "Новый синоним" | spec scenario (add "Нач. остаток" -> parses with no code change) has no test in plan T9/T11/T12 | R-8 |
+| T11 vs T12 | synthetic s01–s13 -> golden snapshots, coverage test may need s14 | OK (plan allows s14) |
+| T11 xlsb | no soffice/LibreOffice on machine (checked) | plan: defer or skip; R-9: allow hand-built BIFF12 writer, else deferred+skip |
+| T12 vs T13 | golden from T12 referenced in docs T13 | OK |
+| each task self-consistency | T0 ok; T1 ok (smoke test ids 66); T2 ok except outline claim (R-10); T3 ok except R-6; T4 ok (needs gridbuilder helper listed); T5 ok except filename-derivation of account code (583 -> 58.03 rule may not fit all 66; R-10 same policy: derive generally, no exception table; mismatch -> ruling); T6 R-2/R-3; T7 ok; T8 R-1; T9 R-3/R-8; T10 ok; T11 R-9; T12 R-4/R-5/R-7; T13 ok |
+
+## Rulings (all recorded also in design.md "Decisions made without discussion")
+
+- Ruling R-1 (design #11): collapsed (single-column) balance is split by sign in `classify.collect_body_rows` (T8): positive -> debit, negative -> credit (abs), plus one warning "свёрнутое сальдо: opening|closing" per sheet; ColumnMap stays as in T4. — spec scenario requires it, no task owned it — cost if wrong: move split into T9.
+- Ruling R-2 (design #12): T6 delivers unit tests only; the fixtures test `test_hierarchy_real.py` (66 files hierarchy=outline, 66.03 parent) is written in T8 on top of `collect_body_rows`. — BodyRow construction needs ACCOUNT_RE/collect_body_rows from T8 — cost: strategy bugs on real files surface at T8 (opus) rather than T6.
+- Ruling R-3 (design #13): `src/parser/deadline.py` (class Deadline) is created in T6 (needed there and in T8), T9 only reuses it; `Deadline` tests live in T6. — ordering — cost: none.
+- Ruling R-4 (design #14): linter «sheet name» rule = string constant that FULLY matches `(?i)^(лист|sheet)[\s_]*\d*$` (default sheet names), not any literal containing «лист»; messages like «лист ОСВ без заголовка» are allowed. — D-6 mandates such messages; D-10 only forbids sheet names — cost: a sheet-name-like literal inside a longer string is not caught (eyes of final review).
+- Ruling R-5 (design #15): linter exempts (a) constants inside annotations (`Literal[...]`), (b) strings equal to values of contract enums/Literals (`DataType`, `FileType`, quarters, `ErrorCode`…) computed from `src.contract`, not hardcoded; all other short uppercase literals fail. — contract values are structure, not coordinates — cost: a column-letter equal to an enum value (e.g. "BU") slips through.
+- Ruling R-6 (design #16): `data_types.yaml` gets `currency_aliases: {RUB: [руб, рубл, rub]}`; `DataTypeRules.currency_aliases: dict[str, list[Pattern]]`; T5 maps currency via it, no "RUB" literal in code. — avoid literal and keep synonyms as data — cost: dictionary field rename.
+- Ruling R-7 (design #17): the AST linter `test_no_coordinates.py` is created in T3 (sonnet), not T12, so tasks 4–9 are checked as they land; T12 extends/verifies it (tmp-tree negative test, coverage). — a late linter would find violations after 8 tasks — cost: T3 slightly larger.
+- Ruling R-8 (design #18): T9 adds a unit test for spec scenario "Новый синоним": tmp copy of rules dir + «Нач. остаток» synonym, in-memory book with that header parses with no code change; default dictionary MUST NOT contain this synonym (T3 note). — scenario had no test — cost: none.
+- Ruling R-9 (design #19): xlsb (T11): no LibreOffice here; the implementer may write a minimal standalone BIFF12 writer (`tests/synthetic/xlsb_writer.py`, test-only) to build `s13`, verified by pyxlsb round-trip; otherwise `deferred` + skip with reason. A hand-built xlsb is not an Excel-produced file -> O-note. — spec scenario XLSB — cost: weaker evidence of real-Excel xlsb compatibility.
+- Ruling R-10 (design #20): numeric claims in plan about real files that were not verified by the plan author (T2 outline "row 15 = 3"; T5 account code from file name incl. «583 -> 58.03») are verified by the implementer; a wrong claim is fixed in the test by a general rule (never an exception table per file), and noted in the report. — Fixtures are not an oracle — cost: none.
+
+## Progress
+Task 0: implemented (docs ec7c42f; here f03bfca..947d2a6), review: T0.1 Important (needs-check example row 3: level/parent inconsistent, data_type NU workaround), T0.2 Minor README v1.0 -> both go to fix round 1
+Task 0: fix round 1/5 dispatched (resumed implementer a56d404d71ee7bfe6)
+Task 0: fix round 1/5 (2 addressed, 0 open; commits 947d2a6..777d4aa, docs ec7c42f..186d6f1)
+Task 0: complete (tabular-parser f03bfca..777d4aa; docs ec7c42f, 186d6f1; review clean)
+ Findings: T0.1 fixed 777d4aa/186d6f1 (row 3 became «Вал.» CURRENCY row level 2: honest single violation; reviewer's suggested BU layouts cannot give a single violation — accepted); T0.2 fixed 777d4aa.
+ O-1: implementer copied examples/ into api/contracts/examples (identity test covers examples; brief missed it) and EXAMPLES_COUNT=9 — accepted.
+Task 1: complete (commits 777d4aa..38386b5, review clean)
+ Task 1: minor T1.1 (deferred): osv_fixture_files не исключает скрытые/lock-файлы (`~$x.xlsx`) — несовместимо с synthetic_files
+ Task 1: minor T1.2 (deferred): ARM_KA_FIXTURES без expanduser()
+ Task 1: minor T1.3 (deferred): пустой osv/ при существующем корне даёт generic "empty parameter set" skip
+ O-2: openspec tasks.md 1.1 называет фикстуру `osv_files`, код/план — `osv_file` (+`synthetic_file`); спека-текст устарел, правится в T13 (tasks.md). Status: deferred (T13).
+Task 2: implemented b5a3b48 (opus implementer a68d0a08ac8cde190), review dispatched. O-3: D-1 в design.md/tasks 1.3 расходятся с брифом по API (row/text/nonempty_rows, bool kind, read_book -> (sheets, warnings)); план главнее по деталям, правка текста — T13. R-10 outline claim verified true.
+Task 2: review: T2.1 Important (whitespace-only cells stored as text) -> fix round 1 dispatched (resumed implementer). Minors: T2.2 (deferred): xlsb BOOLERR hex vs xls "#N/A" mismatch; T2.3 (deferred): fake pyxlsb pads/rows differ from real, real xlsb covered in T11; T2.4 (deferred, pointer for T6): indent = alignment.indent or leading spaces (different units); T2.5 (deferred, pointer for T5/T11): xlsb dates stay numbers (pyxlsb no formats), not in the warning text.
+- Ruling R-11 (design #21): spec-wording drift is reconciled in T13, not now: design D-1 (Sheet API: row/text/nonempty_rows, no bool kind) and tasks.md 1.3 (read_book -> tuple[Sheet,...], «три читателя дают одинаковые ячейки») are updated to the built API `read_book -> (sheets, warnings)`; the three-readers-identical test is dropped (no xls/xlsb writers) — plan is the detailed contract, test coverage is replaced by per-reader tests + real files. Cost if wrong: a reader may later diverge in cell values undetected until golden. [O-3 resolved by R-11]
+Task 2: fix round 1/5 (1 addressed, 0 open — T2.1; commits b5a3b48..c6a768d)
+Task 2: complete (commits 38386b5..c6a768d, review clean)
+ Findings: T2.1 fixed c6a768d. T2.2..T2.5 deferred (see above). O-4 (out-of-scope, deferred): xlsb n_rows/n_cols computed from cells keys (trailing whitespace → smaller dims than xlsx/xls); grid.py docstrings mention whitespace cells partly redundant.
+Task 3: implemented fcd609c (sonnet), review dispatched. Notes for later tasks: T4/T5 — "Контрагенты, ИНН" is one cell in real files (has_inn by label text); Match positions refer to normalized text; values after ":" taken separately. T12 — rules coverage needs synthetic for statement_markers, KPP/code/quantity, roman quarters, date ranges, NU/SUM, Вал./Кол.
+Task 3: complete (commits c6a768d..fcd609c, review clean — Approved, no Critical/Important)
+ Task 3: minor T3.1 (deferred; final review to triage): linter ADDRESS_MAX_LENGTH=4 misses "AB123"/"E1000" (5 chars)
+ Task 3: minor T3.2 (rejected: not a hole; Literal[...] exemption wider than R-5 wording is needed because `Alias = Literal[...]` is not AnnAssign; deviation noted, design #15 covers)
+ Task 3: minor T3.3 (deferred -> pointer T8): test_counterparty_named_like_total is vacuous; real risk «Итого-Сервис» matches total_row => T8 needs structural guard (last level-0 row with sums), not text alone
+ Task 3: minor T3.4 (deferred): normalize_text lacks NFC/NFKC (decomposed ё/й)
+ Task 3: minor T3.5 (deferred): periods «годов» also matches «полугодовой»
+ Task 3: minor T3.6 (deferred -> pointer T4): Pattern.search renormalizes text on every call; T4 should normalize a cell once (search on pre-normalized)
+ Task 3: minor T3.7 (deferred): loader accepts empty lists / patterns matching the empty string
+ Task 3: minor T3.8 (deferred): linter flags literal "sheet"/"лист" alone (dict keys), doesn't catch cell(3,4)/rows[5]/row in (1,2)
+ Task 3: minor T3.9 (deferred): test_rules_real hardcodes BU/RUB for every file
+ Spec check (⚠️ from reviewer): "падение старта при битом словаре" — load_rules is called by get_parser (T9); make sure T9 wires it (cost: none).
+Task 4: implemented a833945 (opus), review dispatched. Pointers: T8 must skip rows after «Итого» that hold only numbers right of the table (RB 60 Q1 2024); D-2/tasks 2.1 text drift (find_osv_sheets, TableHeader.rows/groups) -> T13 (as R-11). T4 added Pattern.search_normalized (T3.6 resolved), ColumnMap.indexes(), explicit OTHER groups in osv_groups.yaml.
+Task 4: review: T4.1 Important (any 2 of 3 groups accepted; spec allows only «начало» missing; preamble false header) + minors T4.2 (side after punctuation in one-row header), T4.3 (INN leading zero 9/11 digits), T4.4 (no min values for 80% INN rule), T4.6 (vertically-merged group title stretched to next group) -> fix round 1 dispatched (resumed implementer). T4.5 (extra_cols collected over footer after «Итого») deferred -> pointer T8. T9 pointer: `columns` diagnostics must add INN letter. T8 pointer: pad INN 9/11 digits with leading zero.
+- Ruling R-12 (design #22): header group row MUST contain turnover and closing (opening optional); candidate rows lacking them are skipped; else InvalidFormatError naming the missing group(s). — spec allows only «начало» to be absent; brief's "≥2 of 3" is a plan defect — cost if wrong: a file with opening+turnover only (no closing) would error instead of parsing partially.
+Task 4: fix round 1/5 (5 addressed, 0 open — T4.1,T4.2,T4.3,T4.4,T4.6; commits a833945..0015532)
+Task 4: complete (commits fcd609c..0015532, review clean). T4.1 fixed 0015532 (R-12), T4.2 fixed 0015532, T4.3 fixed 0015532, T4.4 fixed 0015532, T4.6 fixed 0015532, T4.5 deferred (-> T8 pointer).
+ T4.7 (minor, deferred): numeric KПП column (9 digits) / 11-digit numbers could be mistaken for INN when no «ИНН» header exists (value-based fallback ignores a «КПП» header); mitigation: skip columns whose header matches service_columns["kpp"].
+ O-5 pointers: T8 must use detect.inn_from_cell for counterparty_inn; T9 columns diagnostics add INN letter; is_header_repeat keeps ≥2 groups.
+Task 5: implemented 83dd241 (sonnet), review dispatched. Notes: periods.yaml got required `end` key (PeriodRules.end); linter Task 3 adjusted (Literal[1,2] no longer treated as cell subscript); has_inn omitted (not False) when absent; org name containing «за»/«валюта» is treated as a label (limitation).
+Task 5: review: T5.1 Important (org name containing standalone «за»/«валюта» hijacks period/currency), T5.2 Important (periods.end truncates date ranges «г. - ... г.»); minors T5.3 (digit boundary in test regex), T5.4 (variant-2 has_inn untested), T5.5 (xlsb date head test weak), T5.7 (periods.end required key test) -> fix round 1 dispatched; T5.6 (double _subkonto_items) deferred.
+Task 5: fix round 1/5 (6 addressed, 0 open — T5.1,T5.2,T5.3,T5.4,T5.5,T5.7; commits 83dd241..8e627e8)
+Task 5: complete (commits 5868b34..8e627e8, review clean). T5.1 fixed 8e627e8, T5.2 fixed 8e627e8, T5.3 fixed 8e627e8, T5.4 fixed 8e627e8, T5.5 fixed 8e627e8, T5.7 fixed 8e627e8, T5.6 deferred.
+ T5.8 (minor, deferred): _find_period picks first recognised candidate in reading order, not the title row (org name containing a year/quarter above the title would still win) — one-line fix: prefer candidate that is also _is_title.
+ T5.9 (minor, deferred): currency label without ":" (bare «Валюта» + value in next cell) no longer recognised (deliberate to avoid «Валюта Трейд») → warning "валюта в шапке не найдена".
+Task 6: implemented 1eef422 (opus), review dispatched. Finding by implementer: 2 of 66 real files (schastl 58 and 66, 2 кв 2025) are EMPTY ОСВ (only «Итого», no sums) -> cascade raised InvalidFormatError; implementer added TRIVIAL_STRATEGY="trivial" (<2 rows besides «Итого»).
+- Ruling R-13 (design #23): empty ОСВ (no data rows besides «Итого») is a valid case: hierarchy=`trivial`; «Итого» (TOTAL) is detected as the LAST non-empty row at level 0 whose text matches total_row (structural guard, T3.3) — it does NOT require sums; plan statements "all 66 hierarchy=outline", "metrics.rows > 0 except 66 Q2 2025", "one TOTAL in all 66" are generalized to: outline for every file with data rows; rows==[TOTAL] and metrics.rows==0 for empty ОСВ (property-based, no file list); exactly one TOTAL in all 66. Applies to T8 (TOTAL detection, real test), T9 (property test), T12 (golden). — spec scenario "Пустая ОСВ" already expects rows only TOTAL; the plan assumed one such file, reality has two — cost if wrong: empty ОСВ would be INVALID_FORMAT.
+Task 6: review: T6.1 Important (strategy_sums O(n²), no deadline checks; 4000 rows 7.25 s) + T6.5 minor (deadline tests thin) -> fix round 1 dispatched (resumed implementer a7118b38b9095dee9).
+- Ruling R-14 (design #24): «Итого» is not passed to hierarchy strategies, always level 0 / parent None for all four strategies (T6.2 accepted) — otherwise account_pattern nests it under the last account — cost if wrong: outline/indent accept a sheet whose «Итого» has a non-zero level.
+ R-13 text aligned (T6.3): trivial = fewer than two data rows (code), design #23 reworded.
+ T6.4 pointer (T8/T13): tasks.md 2.3 fixtures clause («hierarchy=outline у всех 66») reworded to the general rule by T8 (test) / T13 (text).
+ T6.6 (minor, deferred): test helper `numbered` needs `# type: ignore[arg-type]`.
+ Pointer T8: choose total_row by text AND position BEFORE levels exist (circular: resolve_levels needs total_row; passing None for an unrecognised «Итого» makes account_pattern nest it at level 1) and test that case.
+Task 6: fix round 1/5 (2 addressed, 0 open — T6.1,T6.5; commits 1eef422..68c5e2c)
+Task 6: complete (commits 8e627e8..68c5e2c, review clean). T6.1 fixed 68c5e2c (deadline in strategies; no algorithmic bound — O(n²) worst case remains, interruptible; deferred: algorithmic bound for sums), T6.5 fixed 68c5e2c, T6.2 rejected (ruling R-14), T6.3 fixed (design #23 wording), T6.4 deferred (T8/T13), T6.6 deferred.
+Task 7: implemented 1f2f050 (+60671bc plan tick) (sonnet), review dispatched. Open question from implementer: inn_digits("ИНН 352819549855") fixed as None.
+- Ruling R-15 (design #25): `inn_digits` (and detect.inn_from_cell as owner) extract the digits of a TEXT cell («ИНН 352819549855» → "352819549855"); result only if 10 or 12 digits, else None; numbers keep the leading-zero padding rule (T4.3). — spec: «counterparty_inn — только цифры длиной 10 или 12 из колонки ИНН»; brief test case lists the prefixed text — cost if wrong: counterparty with text prefix gets null INN (matching by name only).
+Task 7: review: T7.1 Important (inn_digits no text extraction; R-15) -> fix round 1 dispatched (design: inn_from_cell stays strict (detection), numbers.inn_digits = strict-then-extract). T7.2 rejected (arbitrary threshold harmless), T7.3 deferred -> open question for T13 (07-open-questions): leading «+», «.5», «5.» rejected in sums. Pointer T8: counterparty_inn via numbers.inn_digits.
+Task 7: fix round 1/5 (1 addressed, 0 open — T7.1; commits 60671bc..46c427e)
+Task 7: complete (commits 5a3d6f9..46c427e, review clean). T7.1 fixed 46c427e (R-15); T7.2 rejected; T7.3 deferred (open question: «+5», «.5», «5.» rejected); T7.4 (minor, deferred -> pointer T8): digit-gluing across non-adjacent groups in inn_digits is looser than needed — tighten to whitespace/NBSP-separated groups (the INN column may be chosen by header alone); T7.5 (minor, deferred): detect.__all__ lacks INN_LENGTHS.
+Task 8: implemented 663ba17 (T7.4 inn_digits tightening) + 4a16f19 (classify) (opus), review dispatched. Real files: 64 outline, 2 trivial, one TOTAL each, max 0.61 s.
+- Ruling R-16 (design #26): ACCOUNT_RE subaccount segments accept 1–3 letters/digits (Cyrillic/Latin), e.g. «76.АВ», «76.ВА», «76.ОТ» are ACCOUNT rows (8 of 66 real files: РБ 76 and schastl 76 for 2024 Q1–Q3); account_rank = number of segments. — spec regex `^\d{2,3}(\.\d{1,2}){0,2}` does not match real data and misclassifies the row as COUNTERPARTY, shifting group_type of children; trees and sums are unaffected — cost if wrong: a counterparty named «76.АВ»-like text (very unlikely) is classified as account. Docs §1.3 and spec regex text updated in T13; golden (T12) must be made after the fix.
+- Ruling R-17 (design #27): counterparty_inn stays per docs §1.3 line 94 and spec — only from the INN column, else null; INN embedded in the row text («Полиграф, 3528315710», 20 rows in schastl 76) is NOT extracted by the parser (counterparty_name = whole row_value); matching by INN-in-name is arm-core's concern -> new open question in docs 07 (T13). The docs example `result-osv-needs-check.json` (row 2 «ООО Лютик, ИНН 7711111111» with counterparty_inn extracted) contradicts the rule -> fix the example in T13 (row_value «ООО Лютик», INN from an INN column). — cost if wrong: arm-core must parse INN from names itself.
+Task 8: review: T8.1 Important (R-16 letter subaccounts, refined regex segment `(?:\d{1,2}|[А-ЯЁA-Z]{1,3})`, uppercase, no IGNORECASE), minors T8.2 (`,\s*` vs spec `,\s` — R-18 keep tolerant, spec text in T13), T8.3 (weak fixtures property: fix), T8.5 (negative assertion), -> fix round 1 dispatched (resumed implementer ad5a18c290747d470). Deferred: T8.4 (spec wording of «Вал.» rows -> T13 docs; R-19), T8.6 (ИНН+КПП glued cell → None) deferred; residual: phone number in INN column accepted (R-15 residual).
+- Ruling R-18 (design #28): `,\s*` kept in ACCOUNT_RE; spec text edited in T13. Cost: «60,Расчеты» treated as account.
+- Ruling R-19 (design #29): «Вал./Кол./currency code» rows: group_type OTHER, data_type CURRENCY/QUANTITY, no schema position, NO inheritance of counterparty_name/inn (account_code_raw inherited); contract example fixed in T13. Cost: consumers find counterparty via parent.
+- Ruling R-20 (design #30): no «Итого» found → warning + checks.total_row_matches_root=false (→ needs_check) in T9. Cost: file without «Итого» always needs_check.
+Pointer T13 (docs): fix example result-osv-needs-check.json: group_schema with ACCOUNT first, group_name of ACCOUNT row = «Счет», row 2 name per R-17 (row_value «ООО Лютик», INN via INN column), row 3 «Вал.» without counterparty_* (R-19); spec/§1.3 regex text (R-16, R-18, R-19); new open question: INN embedded in row text (R-17) and ИНН/КПП glued cell.
+Task 8: fix round 1/5 (4 addressed, 0 open — T8.1,T8.2,T8.3,T8.5; commits 4a16f19..da7215b)
+Task 8: complete (commits 46c427e..da7215b, review clean). T8.1 fixed da7215b (R-16), T8.2 fixed da7215b (R-18: keep `,\s*`, test names tolerance), T8.3 fixed da7215b, T8.5 fixed da7215b, T8.4 deferred (-> T13 docs), T8.6 deferred. Out-of-scope observations (deferred): `\d` in ACCOUNT_RE matches Unicode digits (re.ASCII optional hardening for final review); «76.АВ.АВ» also accepted by {0,2}.
+Task 9: implemented e705d75 (opus), review dispatched. 66/66 done (2 empty ОСВ), max 0.6 s; test_app and integration round-trip moved from NOT_IMPLEMENTED to real-parser errors (UNSUPPORTED_FORMAT/UNREADABLE).
+- Ruling R-21 (design #31): parent-sum check §1.4 — turnovers are compared per column; closing/opening balances are accepted if equal per column, OR if the PARENT shows the balance netted (at most one non-zero side) and the net `debit − credit` equals the children's net. Reason: 1С shows a parent account netted (62 = 16.1M Dt) while subaccounts are shown unnetted (62.01 = 40.2M Dt, 62.02 = 24.1M Kt); 32 of 66 real files fail the strict six-column rule solely for this reason (all 62 offending parents verified: turnovers and net match). Not a fit-to-converge hack: negatives are pinned by tests (wrong turnover, wrong net -> violation). Spec §1.4 text and domain-rules §1.4 are updated in T13. — cost if wrong: an error where parent's net matches but sides are swapped is not caught here (still caught by row identity and TOTAL=root checks).
+Open observation O-6: with the stock dictionary the header «Нач. остаток» is not recognised: result needs_check, opening_* null, opening values go to extra (R-8 test documents it). Pointer T13: CLAUDE.md of repo does not list PARSE_MAX_BYTES / PARSE_TIMEOUT_SECONDS.
+Task 9: complete (commits da7215b..e705d75, review clean — Approved; R-21 implemented tightly). Minors: T9.1 (deferred) add negative tests for R-21: collapsed parent on the opposite side; parent (0,0) with children (X,X); T9.2 (deferred) order of warnings across sheets not asserted; T9.3 (deferred) redundant parentheses in _is_ok; T9.4 (deferred) get_parser() per file in property test.
+ Pointers T13 (docs/spec): (a) delta spec tabular-recognition-service must also MODIFY requirement «Результат по контракту» (scenario «Запрос с заглушкой парсера (Срез 0)» in openspec/specs/tabular-recognition-service/spec.md:40 still says NOT_IMPLEMENTED by default) — otherwise archived spec contradicts code; (b) design D-7 says «кэш в get_parser()» — no cache, called once at startup; (c) §1.4/spec text: R-21 (netting at any tree level, residual risks RR-1..4), CLAUDE.md of repo lists PARSE_MAX_BYTES/PARSE_TIMEOUT_SECONDS.
+Task 10: implemented 621c0b2 (sonnet), review dispatched. build_result extracted from worker.py (pure, logic unchanged); CLI exit codes 0/1/2 by status, 3 = file missing/usage (argparse.error overridden); CONCERN: src/__init__ imports app -> Settings(), CLI needs ENV_FILE=.env.test (README documents) — deferred (platform template entry point; follow-up candidate); duration_ms non-deterministic -> T12 golden normalises (already volatile).
+Task 10: complete (commits cf532a3..621c0b2, review clean — Approved). Minors: T10.1 (deferred -> T13 README sentence naming the no-env symptom «ValidationError: OPENBAO Field required → ENV_FILE»; follow-up: lazy app in src/__init__.py (platform template)), T10.2 (deferred) CLI uses get_parser() without settings, size limit applied after read_bytes; T10.3 (deferred) test helpers imported across test modules; T10.4 (deferred) integration result key registered for cleanup after asserts; T10.5 (deferred) `--file-type xls` lowercase → English argparse error (normalise with type=str.upper); T10.6 (info) duration_ms non-deterministic.
+Task 11: implemented (sonnet) fixtures 3b9c16b + here e1559c9; 45 synthetic tests; xlsb via handwritten BIFF12 writer (not Excel-produced: O-7 compat unproven; README has replacement instructions); s09 uses «60.АВ» (not 76.АВ); synthetic_files() now filters to Excel files; generator pins dcterms:modified/zip timestamps. Review dispatched.
+Task 11: complete (fixtures 3b9c16b; here 621c0b2..e1559c9, review clean — Approved). Minors: T11.1 (assigned to T12, fixed there): synthetic tests FAIL (not skip) when fixtures dir exists without osv/synthetic (14 failed) -> skip when synthetic dir absent/empty; T11.2 (assigned to T12): comment in s07 that mixed child signs deliberately exercise R-21 (+ optionally a single-sign collapsed variant); T11.3 (deferred) xlsb writer BrtRowHdr payload may be 16 vs 17 bytes per MS-XLSB, Excel may reject (disclosed: hand-built, Excel compat unproven) — add line to writer docstring; T11.4 (deferred -> fixtures README rewording: reader does not expose outline/format, xlsb supports them); T11.5 (deferred) byte reproducibility across zlib/openpyxl/OS not guaranteed (pin create_system=3); T11.6 (deferred) s10 warning row-number substring match; T11.7 (deferred) coverage synonyms embedded in s-files couple scenarios to dictionary; T11.8 (deferred) generate.py minor cleanups; T11.9 info.
+ O-7: xlsb is hand-built (R-9): real Excel-produced xlsb compat unproven — human to replace s13 with an Excel/LibreOffice-converted file (README instruction exists).
+- Ruling R-22 (design #32): rules-coverage test (T12) counts RECOGNITION entries (regex Pattern entries of osv_headers/osv_groups/periods/data_types rows+header+aliases); the `currency_codes` enumeration (ISO 4217 list) is exempt — a code list is not a synonym; entries that never fire on real files/synthetic are either removed from the YAML or covered by a new synthetic file s14_* (allowed, generator + fixtures + test table updated). — spec: «каждая запись словаря SHALL срабатывать»; an ISO list cannot be forced onto fixtures — cost if wrong: dead ISO codes remain.
+Task 12: implemented (sonnet) fixtures fd83a6c (s14–s16) + 8ee574e (82 golden snapshots: 66 real + 16 synthetic), here bc143ef; manual reconciliation of 3 files OK (cells read independently: РБ 60 1кв2025 Итого H677/J677/L677/Q677; schastl 62 xls row 26; 583 → 58.03); R-22 coverage: 4 dictionary entries removed (service_columns.kpp/code/quantity/currency — unused, columns go to extra), 16 covered by s14–s16; linter: ADDRESS_MAX_LENGTH 10 (T3.1), cell(3,4) calls (T3.8); T11.1/T11.2/T3.9 fixed; `make check` ~95 s. Review dispatched.
+Task 12: complete (fixtures fd83a6c, 8ee574e; here afcd7d5..bc143ef, review clean — Approved). Manual reconciliation OK (3 files). Minors: T12.1 (deferred; O-8) s16 variant-2 detail line «…, Валюта» matches header_markers.currency → misleading warning «валюта «» не распознана» frozen in snapshot (currency None either way): a currency label inside the subkonto/detail line should not count as a currency cell — parser defect, low impact; T12.2 (T13) report Decision 5 vs actual: first `make golden-update` prints whole new files as diffs (consider printing only paths for created files); T12.3 (deferred) golden_update deletes orphan snapshots without guard (if osv/synthetic absent → would delete all synthetic snapshots): add guard/--prune; T12.4 (T13 docs) `make check` ≈95 s because `make test` includes the fixtures marker: document in README/CLAUDE.md (or move fixtures to test-fixtures/check-full — controller ruling: document, don't change Makefile); T12.5 (deferred) missing-snapshot failure branch not unit-tested; T12.6 (deferred) golden_helpers imports private cli._error_payload/_file_type; T12.7/T12.8 info.
+Task 13: implemented (sonnet) here 7738221, docs baeb918; make check-full green (1743 passed, integration 9 passed, docker build ok), pytest -m fixtures 808 passed 0 skipped, openspec validate --strict valid in both; added 2 unit tests (new file without snapshot, regression) + check_snapshot moved to golden_helpers (T12.5 fixed); OQ-98…OQ-105 added; example result-osv-needs-check.json got TOTAL row; NOTE: example warnings lack `sheet=<имя>` prefix and organization_inn non-null in example (code writes null) — cosmetic; delta tabular-recognition-service keeps scenario name «Запрос с заглушкой парсера (Срез 0)» (validate --strict) with NullParser-explicit content. Review dispatched.
+Task 13: complete (here bc143ef..7738221, docs baeb918, review clean — Approved). Minors: T13.1 (final fix wave) example result-osv-needs-check.json: warnings should be `sheet=Лист_1 hierarchy=outline` + real Russian violation text from checks.py, organization_inn null, service version — both copies byte-identical; T13.2 (final fix wave) spec/§1.0 «хотя бы одна ячейка» → «в первых 30 строках» (SHEET_MARKER_ROWS); T13.3 (final fix wave) typo in spec.md:122 broken code span; T13.4 (deferred) enumerate residual risks of R-21 (RR-1..4) + (0,0)/(X,X) + «12.ООО» in spec/docs; T13.5 (final fix wave) 06-test-data: «за 2024 г.» → «за 1–3 кв. 2024» (schastl 76); T13.6 (final fix wave) OQ-81 wording: «частично закрыт» in 09-delivery-plan and design.md; T13.7 (final fix wave) task-13-report test name `test_s06_two_osv_sheets_and_other_sheet`; T13.8 (deferred) CLAUDE.md duplicates README module list; T13.9 info (golden_helpers imports pytest).
+ Human remainder: after `openspec archive`, openspec/specs/tabular-recognition-service/spec.md:6 Purpose line «В Срезе 0 разбор заменён заглушкой.» stays stale (delta cannot change Purpose).
+FINAL REVIEW (opus, range b34e113..7738221): no Critical. I-1 Important: reading phase unbounded (xlsx with one 4.9 KB → 24 s, 1.4 GB RSS; PARSE_MAX_BYTES/Deadline don't help; README:188 claims otherwise; no zip expansion ratio cap; defusedxml absent; detect loops without deadline). I-2 Important: unexpected parser exception loses traceback (no logger in src/parser; osv.py:148). M-1 ACCOUNT_RE w/o DOTALL/ASCII (multi-line cell not account; Arabic-Indic digits accepted). M-2 rows after last «Итого» dropped silently. M-3 (=T4.7) numeric КПП column (9 digits padded to 10) can be picked as INN column by value → silent INN=«0»+КПП, status done. M-4 «Итого = первый корень» → needs_check on sheets with several roots (OQ). M-5 hierarchy=sums makes parent-sum check tautological (doc/OQ). M-6 linter blind spots (other names, rows[5], range(5,9), lowercase addresses, TDSheet). M-7 worker downloads whole object before size check. Process: three PRs must merge together (fixtures, docs first, then tabular-parser). Triage: before merge = T13.1/.2/.3/.5/.6/.7 + T9.1 tests + README:188 + I-2 (recommended); rest follow-ups.
+FINAL FIX WAVE (one dispatch, fresh opus): I-1 (partial), I-2, M-1, M-3, T9.1, T1.1, T12.3, T13.1/.2/.3/.5/.6/.7, T13.4 (+ blind spot "both sides +X"), M-4/M-5 as OQs (docs). Deferred: M-2, M-6, M-7, defusedxml (dependency = platform decision), T5.8 etc.
+- Ruling R-23 (design #35): I-1 fixed by (a) cap on total uncompressed size of the xlsx zip (sum ZipInfo.file_size ≤ 10 × PARSE_MAX_BYTES → UNSUPPORTED_FORMAT), (b) pre-scan of `` in xl/worksheets/*.xml rejecting a total merged area above a named constant (UNSUPPORTED_FORMAT with limit text), (c) deadline.check in detect loops (_find_group_row, extra_cols), (d) README claim corrected; defusedxml NOT added (new dependency = platform decision) → follow-up; xls/xlsb: no equivalent merged-range bomb known. — cost if wrong: other bombs (billion laughs) possible until defusedxml/limits are added.
+- Ruling R-24 (design #36): M-3: value-based INN column detection does NOT pad 9/11-digit numbers (padding only when extracting from a column chosen by header or by the strict 80% rule of 10/12 digits); `inn_digits` keeps padding for extraction. — avoids silently taking a numeric КПП column for ИНН; cost: a numeric ИНН column with lost leading zeros and no «ИНН» header is not auto-detected.
+FINAL FIX WAVE done: tabular-parser 9b8b8ea (code: I-1, I-2, M-1, M-3, T9.1, T1.1, T12.3) + 4651513 (docs/spec/example), docs c2005a4; checks: make check 1782 passed, pytest -m fixtures 808 passed, golden-check 82/82 unchanged, integration 9 passed, openspec validate valid both. Limits: MERGED_AREA_LIMIT 200 000 (real max 10 090), ZIP factor 10 (real ratio up to 7.75). Decisions D1 (merged area also checked after reading any format, xls included), D5 (RR-1…RR-4 written by implementer, not defined elsewhere), I-2 tests print stack (pytest --capture=no). Scoped re-review dispatched (opus).
+FINAL FIX WAVE re-review (opus): I-2, M-1, M-3, T9.1, T1.1, T12.3, T13.1–T13.6, OQ-106..109 ADDRESSED; I-1 NOT ADDRESSED in part (b): merge pre-scan bypassable 4 ways (sheet part outside xl/worksheets/; XML charref in ref `Z200000`; namespaced attr before `ref` `a:ref="A1" ref="…"`; `>` inside a quoted attribute) — each restores 24 s / ~1.4 GB from <5 KB file; (a),(c),(d) fine. Residual risks (not covered by R-23, real): cell-volume bomb within zip cap (5 MB xlsx with 2M plain cells → 13 s / 1.5 GB), sharedStrings/styles volume, XML entity expansion (OQ-108). New Minors: N1 after-read `check_merged_area` counts spans > grid._MAX_INDEXED_AREA (10 000) that the grid never expands — a legit whole-column merge in xls now UNSUPPORTED_FORMAT; N2 blind-spot wording «все предки свёрнуты» narrower than the real risk (a collapsed direct parent suffices) in spec.md:182 and 05-domain-rules.md:150.
+- Ruling R-25 (design #37): second (and last) fix round authorized by the human's instruction «C/I — исправлять свежим исполнителем с повторным ревью»: fresh opus implementer replaces the regex pre-scan by a streaming `xml.parsers.expat` scan of ALL `.xml` parts of the zip (namespace-aware: only un-prefixed `ref` on `mergeCell`; charrefs decoded; quoted `>` safe), early abort on area > limit, malformed XML → UNREADABLE; tests for the 4 bypasses + original; N1 (count only spans ≤ the grid indexing threshold in the after-read check) and N2 wording fixed; the cell-volume/shared-strings bomb and entity expansion stay documented residual risks (README «Известные ограничения», OQ-108 extended) — container memory limits/defusedxml are platform decisions. After this round there is no further wave: any residual goes to «Rulings» / follow-up.
+FINAL FIX round 2 done: tabular-parser 6f579b6 (streaming expat scan of ALL zip entries incl. parts that merely look like XML (openpyxl reads xl/data/s.dat — 5th bypass), mergeCell counted in any namespace, ref via openpyxl CellRange, grid.MAX_INDEXED_AREA public, after-read counts only indexable spans, openpyxl_forbidden fixture records calls), docs 68b50a5; checks: test_read_limits 46 passed, make check 1810 passed, fixtures 808 passed, golden-check 82/82, integration 9 passed, openspec valid both; scan cost 12 ms vs 0.58 s (largest real xlsx); 0.8 s vs 10 s (2M cells). Risks: broken *.xml part never read by openpyxl (customXml) → UNREADABLE; billion-laughs protection relies on expat ≥ 2.4 (platform image unverified). Scoped re-review (opus) dispatched.
+FINAL FIX round 2 re-review (opus): N1, N2, README/OQ-108 text ADDRESSED; I-1(b) NOT ADDRESSED: the "looks like XML" sniff (_limits.py:137-149, _SNIFF_BYTES=64, BOM/NUL/whitespace stripped, requires `<`) lets a sheet part named non-`*.xml` (e.g. xl/data/s.dat, Target in workbook.xml.rels) with ≥64 bytes of leading whitespace (or UTF-16 BOM + whitespace), no XML declaration, evade the scan: 1837-byte file → read_book 27.75 s / 1.35 GB (repro /tmp/rr2/N7.xlsx; v.py+h.py). Suggested minimal fix: drop the sniff, feed every non-directory non-*.xml entry to expat in lenient mode (binary fails on the first bytes) + tests. Held: the 4 original bypasses, .dat, DOCTYPE entity/ATTLIST default, sheet-qualified/$-absolute refs, straddling tag, deep nesting, entity bombs (expat 2.6.3 amplification limit, 0.05 s), ref fuzz. New Minors: #2 N1 opens a CPU vector — xls with 100 000 spans of area 10 001: merged_span scans `_large_merged` linearly, 4.4 ms/lookup → cut only by the 120 s Deadline (fix: cap on the number of large spans ≤1000 or interval index); #3 scan memory is not bounded by chunk size: junk part with a 400 MB attribute → 1.3 GB RSS/1.24 s, 400 MB of `` → 8.5 s CPU in the scan alone, parts openpyxl never reads; older expat (<2.6.0) quadratic on huge tokens; README claim "every part whose start looks like XML is scanned" contradicts the bypass; OQ-105 still says «под свёрнутыми предками» (docs 07-open-questions.md:113).
+- Ruling R-26 (design #38): NO third fix wave (skill: one final wave + one re-review; R-25 already stretched it once on the human's instruction «C/I — исправлять свежим исполнителем с повторным ревью»): the pre-scan is defence-in-depth, NOT a security boundary — it blocks the generic/accidental merge bomb and all standard-form bypasses found, but a deliberately hand-crafted file (non-`.xml` sheet part + ≥64 bytes leading whitespace, no XML declaration) still reaches openpyxl (27 s / 1.4 GB); the real boundary must be a container memory limit and/or subprocess with RLIMIT (platform decision, OQ-108). Docs-only correction dispatched (README claim, OQ-108/OQ-105, new minors listed). TOP FOLLOW-UP for the human: «drop the sniff; scan every non-directory entry leniently with expat» (≈10 lines + test) and the large-span count cap. — cost if wrong: a crafted file takes a worker for ~30 s / 1.4 GB until the follow-up or a platform memory limit; files come from authenticated users via the internal IS, not anonymous.
diff --git a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md
new file mode 100644
index 0000000..abafaf7
--- /dev/null
+++ b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md
@@ -0,0 +1,446 @@
+# Срез 2a — парсер ОСВ (tabular-parser) — план исполнения
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Branch:** `feat/5-slice-2a-osv-parser` (worktree `.wt/slice-2/tabular-parser`). **Issue:** RSHB/arm-tabular-parser#5. **PR:** #6 (открыт, спека).
+**Соседние worktree той же фичи:** docs `.wt/slice-2/docs` (ветка `feat/9-slice-2a-osv-parser`, issue #9, PR #10), fixtures `.wt/slice-2/fixtures` (ветка `feat/4-slice-2a-osv-parser`, issue #4, PR создаёт человек-сессия). `../arm-core` — симлинк на main, **только чтение**.
+**Spec:** `openspec/changes/slice-2a-osv-parser/` — proposal.md, design.md (D-1…D-10, таблица решений №1–10), specs/osv-parsing/spec.md (9 требований), specs/tabular-recognition-service/spec.md. Правила: `../docs/context/05-domain-rules.md` §1.0–§1.5 (строки 8–137), наблюдения по реальным файлам — `../docs/context/06-test-data.md` §3 (строки 72–103). Спека — единственный источник истины при рулингах; план сгенерирован из `tasks.md` change'а (номера в скобках), по завершении задачи отметить `[x]` и там.
+
+**Goal:** `get_parser()` возвращает парсер, который разбирает все 66 реальных ОСВ и всю синтетику в `osvDocument` контракта v1.1 с выполненными инвариантами §1.4, без единого правила под файл или координату.
+
+**Architecture:** читатели xlsx/xls/xlsb → единая `Sheet` → детекторы по содержимому (листы, заголовок, колонки) → шапка и `group_schema` → каскад иерархии → классификация → Decimal → контрольные суммы → `ParseOutcome`. Словари YAML, лимиты и кооперативный таймаут, golden и property-тесты на fixtures, AST-линтер координат.
+
+**Tech Stack:** Python 3.11, pydantic v2 (модели контракта `src/contract`), openpyxl 3.1.5, xlrd 2.0.2, pyxlsb 1.0.10, PyYAML, pytest; без БД.
+
+## Global Constraints
+
+- **Без координат.** В `src/parser/**` (кроме `grid.py` и `readers/`) запрещены литералы адресов/букв колонок (`"E9"`, `"A"`), имён листов, сравнения с номерами строк. Буквы колонок появляются только в диагностике `columns` через `grid.column_letter(index)`. Проверяет тест Task 12; нарушение — не «готово».
+- **Правила — данные.** Все синонимы и регулярки распознавания — в `api/rules/*.yaml`; код содержит только структуру алгоритма. Новый формат = фикстура + синоним.
+- **Контракт v1.1** — `../docs/contracts/recognition-result.schema.json` (после Task 0), копия в `api/contracts/` байт в байт (тест идентичности). Деньги — `Decimal` → строка `^-?\d+(\.\d{1,2})?$`; `float` в домене запрещён. Поля результата — только из контракта; `rows[].extra` — `dict[str, str | None]`.
+- **Статусы и коды** — ровно по D-6 спеки: `INVALID_DOCUMENT` (нет листов ОСВ), `INVALID_FORMAT` (нет заголовка/колонок), `UNREADABLE`, `UNSUPPORTED_FORMAT` (формат или лимит размера), `TIMEOUT`, `RECOGNITION_FAILED`; нарушение §1.4 → `needs_check`, причины в `metrics.warnings`, номера строк в `checks`. Парсер не подгоняет структуру под сходимость.
+- **Воркер, NATS, S3 не меняются** (кроме `test_worker` под `schema_version 1.1`). `ParseOutcome` требует непустые `documents` при `done|needs_check`.
+- **Фикстуры** — `../fixtures/osv/**` (66 файлов: 48 xlsx, 18 xls), доступ через `ARM_KA_FIXTURES` или соседний каталог; без каталога — пропуск с причиной. Фикстуры — регрессия, не оракул: правило под конкретный файл запрещено.
+- **Производительность:** любой файл fixtures < 5 с; `PARSE_MAX_BYTES = 50 MiB`, `PARSE_TIMEOUT_SECONDS = 120`.
+- **Линтеры:** ruff (line-length 120, E,F,I,UP,B,PL — `PLR2004` решается константами, `PLR0913` — dataclass параметров или `# noqa: PLR0913 - причина`) + mypy (`check_untyped_defs`; для `src/parser/**` в `pyproject.toml` добавить override `disallow_untyped_defs = true`). Все команды — из `api/` (`make` — из корня). Тесты: маркеры `integration` (NATS/MinIO) и `fixtures` (реальные данные), `pytest.ini` уже объявляет оба.
+- **Коммиты:** русский язык, по задаче; последние строки — `Co-Authored-By: Claude <модель исполнителя> ` и `Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2`. Не пушить, не создавать PR, не `openspec archive`. Коммиты в `../docs` и `../fixtures` — в их worktree, те же правила.
+- **Допущения в коде:** `# ASSUMPTION(OQ-02)` (is_cumulative по тексту), `# ASSUMPTION(OQ-45)` (NU как есть), `# ASSUMPTION(OQ-01)` (TOTAL в rows).
+
+## Review Focus
+
+1. **Строка счёта без сумм в части колонок (все `null`)** — инвариант сальдо считает `null` как 0 и не падает; `extra` не содержит ключей-дубликатов: Task 8 — тест.
+2. **Числа, записанные с процентным или денежным форматом (openpyxl даёт `float`), и `int` в ячейках** — `Decimal(str(value))` без хвостов вида `.000000001`: Task 7 — тест на `0.1 + 0.2`-подобных float и `int`.
+3. **Два контрагента с одинаковым именем под разными субсчетами** — `parent_row_number` различает их, инвариант «сумма детей» считается по дереву, а не по имени: Task 6 — тест.
+4. **Лист ОСВ, где заголовок таблицы повторяется на каждой «странице» печати** (повтор строки «Счет | Сальдо на начало…» в середине данных) — повторы заголовка пропускаются как не-данные, а не классифицируются как OTHER: Task 5 — тест на синтетической сетке.
+5. **Пустые строки между блоками данных и пробелы вместо пустоты (`" "`)** — считаются пустыми, не рвут дерево: Task 8 — тест.
+
+---
+
+## Задачи
+
+### Task 0: [docs 1.1] Контракт v1.1 в `../docs` и копия в `api/contracts/`
+
+**Files (worktree `.wt/slice-2/docs`, ветка `feat/9-slice-2a-osv-parser`):**
+- Modify: `contracts/recognition-result.schema.json`, `contracts/examples/result-osv-minimal.json`
+- Create: `contracts/examples/result-osv-needs-check.json`
+- Modify: `openspec/changes/slice-2a-osv-parser/tasks.md` (1.1 → `[x]`)
+
+**Files (этот репозиторий):**
+- Modify: `api/contracts/recognition-result.schema.json` (копия), `api/src/contract/result.py` (`OsvRow.extra`), `api/src/contract/common.py` (`CONTRACT_VERSION = "1.1"`), `tests/unit/test_worker.py` и `tests/unit/test_contract.py` (ожидания `1.1`)
+
+**Interfaces:**
+- Схема: в `osvDocument.rows.items.properties` добавить
+ ```json
+ "extra": { "type": ["object", "null"], "description": "нераспознанные колонки внутри таблицы: заголовок колонки → текст ячейки (§1.2)", "additionalProperties": { "type": ["string", "null"] } }
+ ```
+ `title` → «… контракт v1.1», `schema_version.description` → «производитель контракта v1.1 пишет "1.1"». Pattern `^1\.\d+$` не меняется.
+- `OsvRow.extra: dict[str, str | None] | None = None`.
+- `CONTRACT_VERSION = "1.1"` — воркер пишет его в `schema_version` файла и `result_schema_version` конверта (уже так).
+- Пример `result-osv-needs-check.json`: статус `needs_check`, один документ, `checks.balance_identity_violations = [3]`, `metrics.warnings = ["hierarchy=outline", "balance identity violated at rows [3]"]`, `schema_version "1.1"`, у строки 3 `extra: {"КПП": "352801001"}`.
+
+- [x] **Step 1 (docs):** внести правки схемы и примеров; проверить `python -c "import json,jsonschema; ..."` — оба примера `result-osv-*.json` валидны по новой схеме, старый `result-osv-minimal.json` без `extra` тоже валиден; `openspec validate slice-2a-osv-parser --strict` в docs → valid. Commit в docs: `contracts: результат v1.1 — rows[].extra (§1.2), пример needs_check`.
+- [x] **Step 2 (здесь):** скопировать схему, добавить поле модели и версию; `pytest tests/unit/test_contract.py tests/unit/test_worker.py -v` → сначала падает на версии/идентичности, затем PASS после правок ожиданий (`"1.1"`).
+- [x] **Step 3:** `make check` → PASS. Commit: `contract: v1.1 — OsvRow.extra, CONTRACT_VERSION 1.1, копия схемы`.
+
+---
+
+### Task 1: [1.1] Зависимости и доступ к фикстурам
+
+**Files:**
+- Modify: `api/requirements.txt` (`openpyxl==3.1.5`, `pyxlsb==1.0.10`, `xlrd==2.0.2` по алфавиту; `et_xmlfile` подтянется транзитивно — закрепить `et_xmlfile==2.0.0`, если `uv pip install` его ставит)
+- Create: `api/tests/fixtures_helpers.py`, модифицировать `api/tests/conftest.py`
+- Modify: `api/tests/unit/test_layout.py` (страж не должен запрещать новые пакеты — проверить), `api/pyproject.toml` (mypy override для `openpyxl.*`, `xlrd.*`, `pyxlsb.*` — `ignore_missing_imports = true`; strict-флаги для `src.parser.*`)
+
+**Interfaces:**
+```python
+# tests/fixtures_helpers.py
+FIXTURES_ENV = "ARM_KA_FIXTURES"
+def resolve_fixtures_dir() -> Path # env или Path(__file__).resolve().parents[3] / "fixtures"
+def fixtures_skip_reason() -> str | None
+def osv_fixture_files(root: Path) -> list[Path] # все *.xls/*.xlsx/*.xlsb под root/"osv", кроме каталога synthetic, отсортированы
+def synthetic_files(root: Path) -> list[Path] # root/"osv"/"synthetic"/*
+# conftest.py
+@pytest.fixture(scope="session") def fixtures_dir() -> Path # skip с причиной
+def pytest_generate_tests(metafunc): параметризует аргумент `osv_file` всеми osv_fixture_files (ids — относительный путь), `synthetic_file` — synthetic_files; при недоступном каталоге — один параметр с pytest.param(marks=skip)
+def pytest_collection_modifyitems: тестам с маркером `fixtures` — skip при недоступном каталоге
+```
+
+- [x] **Step 1:** тест `tests/unit/test_fixtures_helpers.py`: при `ARM_KA_FIXTURES=` без `osv/` — `osv_fixture_files` пуст; с двумя файлами и `synthetic/` — синтетика исключена; `fixtures_skip_reason()` даёт текст с путём.
+- [x] **Step 2:** реализовать; `make venv`; `pytest -m fixtures --collect-only -q | tail -3` показывает 66 параметров (через временный тест-заглушку `test_smoke_fixtures(osv_file)` с `assert osv_file.exists()`, который остаётся как дымовой).
+- [x] **Step 3:** `make check` → PASS. Commit: `test: доступ к ../fixtures (ARM_KA_FIXTURES), параметризация по 66 ОСВ; зависимости openpyxl/xlrd/pyxlsb`.
+
+---
+
+### Task 2: [1.3] Модель листа `grid.py` и читатели
+
+**Files:**
+- Create: `api/src/parser/grid.py`, `api/src/parser/readers/__init__.py`, `api/src/parser/readers/xlsx.py`, `api/src/parser/readers/xls.py`, `api/src/parser/readers/xlsb.py`, `api/src/parser/errors.py`
+- Test: `api/tests/unit/parser/test_grid.py`, `api/tests/unit/parser/test_readers.py`, `api/tests/fixtures/test_readers_real.py`
+
+**Interfaces:**
+```python
+# errors.py
+class ParserError(Exception): code: ErrorCode; message: str # базовый, превращается в ParseOutcome(error)
+class UnreadableError(ParserError) # code UNREADABLE
+class UnsupportedFormatError(ParserError) # code UNSUPPORTED_FORMAT
+class InvalidFormatError(ParserError) # code INVALID_FORMAT
+class InvalidDocumentError(ParserError) # code INVALID_DOCUMENT
+class ParseTimeoutError(ParserError) # code TIMEOUT
+
+# grid.py
+CellKind = Literal["empty", "text", "number", "date", "bool"]
+@dataclass(frozen=True, slots=True)
+class Cell:
+ value: str | Decimal | datetime.date | datetime.datetime | bool | None
+ kind: CellKind
+ bold: bool = False
+ indent: int = 0 # alignment.indent или число ведущих пробелов текста
+ @property
+ def text(self) -> str # строка для сопоставления: str(value).strip() или "" ("пробелы" = пусто)
+@dataclass(frozen=True, slots=True)
+class MergedRange: top: int; left: int; bottom: int; right: int # 1-based включительно
+@dataclass(frozen=True)
+class Sheet:
+ name: str
+ cells: Mapping[tuple[int, int], Cell] # только непустые; (row, col) 1-based
+ merged: tuple[MergedRange, ...]
+ row_outline: Mapping[int, int] # row → outline level (0 по умолчанию)
+ n_rows: int; n_cols: int
+ def cell(self, row: int, col: int) -> Cell # EMPTY_CELL если нет
+ def text_at(self, row: int, col: int) -> str # с учётом merged: значение origin диапазона
+ def merged_origin(self, row: int, col: int) -> tuple[int, int]
+ def merged_span(self, row: int, col: int) -> MergedRange | None
+ def row_cells(self, row: int) -> list[tuple[int, Cell]] # непустые (col, cell) по возрастанию col
+ def is_row_empty(self, row: int) -> bool
+ def outline(self, row: int) -> int
+def column_letter(index: int) -> str # 1 → "A", 27 → "AA" (только для диагностики)
+EMPTY_CELL = Cell(None, "empty")
+
+# readers/__init__.py
+def read_book(data: bytes, file_type: FileType) -> tuple[tuple[Sheet, ...], list[str]]
+# по сигнатуре: PK\x03\x04 → xlsx/xlsb (различать по наличию xl/workbook.bin в zip), D0 CF 11 E0 → xls;
+# несовпадение с file_type → предупреждение "file_type=XLSX, но содержимое BIFF8: прочитано как XLS";
+# иная сигнатура → UnsupportedFormatError; исключения библиотек → UnreadableError(причина одной строкой)
+```
+Числа: читатели кладут `Decimal(str(value))` для `float`/`int` (без квантования — это делает `numbers.py`), bool отдельно (`kind="bool"`), даты — как есть. xlsx: `load_workbook(BytesIO(data), data_only=True, read_only=False)`; outline — `ws.row_dimensions[r].outline_level`; bold — `cell.font.bold`; indent — `cell.alignment.indent`; merged — `ws.merged_cells.ranges`. xls: `xlrd.open_workbook(file_contents=data, formatting_info=True)`; outline — `sheet.rowinfo_map[r].outline_level`; bold/indent — через `book.xf_list[cell.xf_index]` (`font.bold`, `alignment.indent_level`); merged — `sheet.merged_cells` (полуоткрытые → включительные). xlsb: `pyxlsb.open_workbook(BytesIO)`; outline/merged недоступны → пустые, предупреждение `"xlsb: outline level недоступен"`.
+
+- [x] **Step 1: Тесты.** `test_grid.py`: `text_at` в merged-диапазоне берёт origin; `row_cells` упорядочен; `column_letter(1/26/27/52/703)`; `Cell(" ").text == ""`. `test_readers.py`: синтетическая книга, собранная openpyxl в памяти (2 листа, merged заголовок, outline 0/1/2, bold, indent, число, текст, дата, пустая строка) → `read_book` даёт ожидаемую `Sheet`; та же книга, сохранённая как xls невозможна без Excel — для xls используется реальный файл в `test_readers_real.py`; подмена `file_type` (XLSX при BIFF8) → предупреждение; мусорные байты → `UnsupportedFormatError`; обрезанный zip → `UnreadableError`. `test_readers_real.py` (`fixtures`): все 66 файлов читаются < 5 с, у каждого ≥ 1 лист, есть строки с outline ≥ 1, merged > 0; `xls` `…62 за 3 квартал 2024 г..xls` — outline строки 15 (1-based) равен 3.
+- [x] **Step 2:** падает → **Step 3:** реализация → **Step 4:** `pytest tests/unit/parser tests/fixtures/test_readers_real.py -v`, `make check` → PASS.
+- [x] **Step 5: Commit** `feat(parser): модель листа Sheet и читатели xlsx/xls/xlsb`.
+
+---
+
+### Task 3: [1.4] Словари `rules/*.yaml` и загрузчик
+
+**Files:**
+- Create: `api/rules/osv_headers.yaml`, `api/rules/osv_groups.yaml`, `api/rules/periods.yaml`, `api/rules/data_types.yaml`, `api/src/parser/rules.py`
+- Modify: `api/rules/README.md`
+- Test: `api/tests/unit/parser/test_rules.py`
+
+**Interfaces:**
+```python
+# rules.py
+def normalize_text(s: str) -> str # lower, ё→е, NBSP/переносы/дефисы-переносы → пробел, схлопнуть пробелы, strip
+class Pattern: # обёртка над re.Pattern, матчит normalize_text(text)
+ def search(self, text: str) -> re.Match | None
+class OsvHeaderRules(BaseModel):
+ sheet_markers: list[Pattern] # «оборотно сальдовая ведомость»
+ statement_markers: list[Pattern] # «бухгалтерский баланс», «0710001», «отчет о финансовых результатах», «0710002» — для пропуска листов форм
+ groups: dict[Literal["opening","turnover","closing"], list[Pattern]] # «сальдо на начало», «нач. сальдо», …
+ debit: list[Pattern]; credit: list[Pattern] # «дебет», «дт», «д-т»
+ service_columns: dict[str, list[Pattern]] # inn: «инн»; kpp, code, quantity, currency — для имён extra
+ total_row: list[Pattern] # «итого»
+ header_markers: dict[Literal["account","period","data_type","currency","org_params","subkonto"], list[Pattern]]
+ # account: «по счету», period: «за », data_type: «выводимые данные», currency: «валюта», subkonto: «субконто», «детализация»
+class OsvGroupRules(BaseModel): mapping: list[tuple[Pattern, Literal["ACCOUNT","DEPARTMENT","COUNTERPARTY","CONTRACT","OTHER"]]]
+class PeriodRule(BaseModel): pattern: Pattern; quarter: Literal["Q1","Q2","Q3","Q4"]; is_cumulative: bool
+class PeriodRules(BaseModel): rules: list[PeriodRule]; year: Pattern # год — четыре цифры
+class DataTypeRules(BaseModel): header: list[tuple[Pattern, DataType]]; row: list[tuple[Pattern, Literal["CURRENCY","QUANTITY"]]]; currency_codes: list[str]
+@dataclass(frozen=True)
+class Rules: headers: OsvHeaderRules; groups: OsvGroupRules; periods: PeriodRules; data_types: DataTypeRules
+def load_rules(path: Path = DEFAULT_RULES_DIR) -> Rules # pydantic-валидация; ошибка → RuntimeError с файлом и ключом
+```
+Содержимое словарей — по §1.0 п. 1–3, §1.1 (таблица group_type), §1.5 (таблица периодов, включая «I кв.», «полугодие», «9 месяцев», «январь-сентябрь», «год», «12 месяцев», диапазоны дат `01.01–31.03`), §1.3 (Вал./Кол., коды валют ISO 4217 буквенные — минимум RUB, USD, EUR, CNY, KZT, BYN, GBP, CHF, JPY, TRY, AED). Порядок правил периода — от более специфичных к общим («9 месяцев» раньше «месяц», «12 месяцев» раньше «2 месяца»-подобных), задаётся порядком списка, первый матч побеждает; тест §1.5 проверяет каждую строку таблицы.
+
+- [x] **Step 1: Тесты** `test_rules.py`: `normalize_text("Оборотно-сальдовая\nведомость") == "оборотно сальдовая ведомость"`, `normalize_text("ОБОРОТНО–САЛЬДОВАЯ") `; каждая строка таблицы §1.5 → ожидаемые `quarter`/`is_cumulative`; «Договоры контрагентов» → CONTRACT, «Номенклатура» → OTHER; битый YAML (нет ключа `groups`) → `RuntimeError` с именем файла.
+- [x] **Step 2–4:** падает → словари и загрузчик → `pytest tests/unit/parser/test_rules.py -v`, `make check`.
+- [x] **Step 5: Commit** `feat(rules): словари ОСВ (заголовки, группировки, периоды, типы данных) и загрузчик`.
+
+---
+
+### Task 4: [2.1] Детекторы: листы ОСВ, заголовок таблицы, колонки
+
+**Files:**
+- Create: `api/src/parser/detect.py`
+- Test: `api/tests/unit/parser/test_detect.py`, `api/tests/fixtures/test_detect_real.py`, `api/tests/unit/parser/gridbuilder.py` (хелпер сборки `Sheet` из текстовой «картинки» для тестов)
+
+**Interfaces:**
+```python
+@dataclass(frozen=True)
+class ColumnMap:
+ opening_debit: int | None; opening_credit: int | None
+ turnover_debit: int | None; turnover_credit: int | None
+ closing_debit: int | None; closing_credit: int | None
+ collapsed: frozenset[Literal["opening","closing"]] # группы со свёрнутым сальдо (одна колонка; индекс лежит в *_debit)
+ def as_diagnostics(self) -> dict[str, str] # {"opening_debit": "E", ...} через column_letter
+@dataclass(frozen=True)
+class TableHeader:
+ first_row: int; last_row: int # блок заголовка (включительно)
+ columns: ColumnMap
+ text_col: int
+ inn_col: int | None
+ right_edge: int # последняя колонка таблицы
+ extra_cols: dict[int, str] # col → заголовок (или буква, если заголовка нет)
+ group_label_rows: list[int] # строки блока, где в text_col стоит текст группировки («Счет», «Контрагенты», …)
+class SheetKind(StrEnum): OSV = "osv"; STATEMENT = "statement"; OTHER = "other"
+def classify_sheet(sheet: Sheet, rules: Rules) -> SheetKind
+def find_table_header(sheet: Sheet, rules: Rules) -> TableHeader # InvalidFormatError с названием ненайденного
+def first_data_row(sheet: Sheet, header: TableHeader) -> int # первая непустая строка после last_row
+def is_header_repeat(sheet: Sheet, row: int, header: TableHeader, rules: Rules) -> bool # повтор заголовка внутри данных (Review Focus 4)
+```
+Алгоритм `find_table_header`: для каждой непустой строки r (сверху) собрать тексты ячеек с учётом merged; строка-кандидат — где найдены ≥ 2 из трёх групп (`groups`), при этом текст группы лежит в merged-диапазоне или одиночной ячейке → `ColumnSpan(left, right)`. Для каждой группы искать «дебет»/«кредит» в строках r+1..r+3 внутри span (origin merged); найдены оба → индексы; найдена одна колонка без подзаголовков (span ширины 1 или пустые подзаголовки) → свёрнутое сальдо (`collapsed`); группа «turnover» свёрнутой быть не может → `InvalidFormatError`. `last_row` = максимум строк, где встретились подзаголовки или метки группировок (ячейки text_col до первой строки данных: строка считается частью блока, если её текст матчит `osv_groups` или `service_columns.inn` или `header_markers.subkonto`). `text_col` = минимальный col среди непустых ячеек первых 20 строк данных, лежащий левее `min(всех индексов сумм)`. `inn_col`: колонка между text_col и первой суммой с заголовком `inn` в блоке, иначе колонка, где ≥ 80 % непустых значений первых 200 строк данных — 10/12 цифр. `extra_cols`: прочие колонки между text_col и right_edge, не входящие в суммы/ИНН, у которых есть хоть одно непустое значение в данных; имя — текст заголовка в блоке над колонкой или буква. `classify_sheet`: OSV если любая ячейка (первые 30 строк) матчит `sheet_markers`; иначе STATEMENT если матчит `statement_markers`; иначе OTHER.
+
+- [x] **Step 1: Тесты.** `gridbuilder.sheet_from_rows([...])` строит `Sheet` из списка строк (значения, `M(text, width)` для merged, `L(level)` для outline). Юнит-сценарии: заголовок 2 строки (вариант 1 РБ), 3 строки с `ИНН` (Счастливочная), 4 строки, вариант 2 («Субконто»), без «начала», свёрнутое «конец», «Обороты» свёрнутые → `InvalidFormatError`, КПП между ИНН и суммами → `extra_cols`, мусор правее `right_edge` не в `extra_cols`, повтор заголовка в данных → `is_header_repeat`, `classify_sheet` для ОСВ / ББ / прочего. `test_detect_real.py` (`fixtures`): для всех 66 — `find_table_header` без ошибок, все шесть индексов заданы, `collapsed` пуст; для `rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx` диагностика `E/H/J/L/O/Q`, `inn_col is None`; для `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls` — `G/J/K/N/Q/S`, `inn_col` = E (5), `group_label_rows` длиной 3.
+- [x] **Step 2–4:** падает → реализация → тесты, `make check`.
+- [x] **Step 5: Commit** `feat(parser): детекторы листов ОСВ, заголовка таблицы и колонок по содержимому`.
+
+---
+
+### Task 5: [2.2] Шапка документа и `group_schema`
+
+**Files:**
+- Create: `api/src/parser/header.py`
+- Test: `api/tests/unit/parser/test_header.py`, `api/tests/fixtures/test_header_real.py`
+
+**Interfaces:**
+```python
+@dataclass(frozen=True)
+class DocHeader:
+ organization_name: str; account_code_raw: str; period_text: str
+ period_year: int | None; period_quarter: Literal["Q1","Q2","Q3","Q4"] | None; is_cumulative: bool | None
+ data_type: DataType | None; currency: str | None; header_variant: Literal[1, 2]
+ warnings: tuple[str, ...]
+def parse_doc_header(sheet: Sheet, header: TableHeader, rules: Rules) -> DocHeader
+def build_group_schema(sheet: Sheet, header: TableHeader, rules: Rules) -> tuple[list[OsvGroup], Literal[1, 2]]
+```
+`parse_doc_header`: область — строки 1..header.first_row−1; организация — первая непустая текстовая ячейка, не матчащая ни один `header_markers` и `sheet_markers`; счёт — текст после `header_markers.account` до пробела/«за»; период — после `header_markers.period` до «г.»/конца строки (для варианта 2 — строка «Период: …»); отсутствие → `""` + предупреждение; `data_type` по `data_types.header`; валюта по `header_markers.currency` → код (`RUB` для «RUB», «руб», «рубл»); год — `periods.year`; квартал/нарастание — первый матч `periods.rules` (`# ASSUMPTION(OQ-02)`). `build_group_schema`: вариант 1 — тексты `text_col` в `group_label_rows` сверху вниз → первая всегда `ACCOUNT` (даже если текст «Счет»), остальные по `osv_groups`; `has_inn` — в той же строке есть ячейка `service_columns.inn`; вариант 2 — если в области шапки есть строка с `header_markers.subkonto`: `ACCOUNT` + элементы через запятую после двоеточия.
+
+- [x] **Step 1: Тесты.** Юнит: вариант 1 с «Счет/Контрагенты/Договоры», с «Подразделение/Контрагенты ИНН» → `has_inn` у COUNTERPARTY; вариант 2; шапка без организации → `""` + warning; счёт «58.03»; все строки таблицы §1.5 через `parse_doc_header` на мини-сетке; валюта «руб.» и «RUB» → `RUB`. `test_header_real.py` (`fixtures`): для всех 66 `account_code_raw` совпадает с кодом из имени файла (регулярка `(\d{2,3}(?:\.\d{1,2})?)` по имени; для `сч 583_` ожидать `58.03`, для `ОСВ_01_ОС` — `01`; таблица исключений в тесте запрещена — вместо неё нормализация имени каталога/файла одной регуляркой, 583 → `58.03` по правилу «три цифры = счёт+субсчёт»), `period_quarter` не `None`, для каталога `2024-FY` `is_cumulative is True`, для `*-Q3` — `False`; `organization_name` непустой; `group_schema[0].type == "ACCOUNT"`.
+- [x] **Step 2–4:** падает → реализация → тесты, `make check`.
+- [x] **Step 5: Commit** `feat(parser): шапка ОСВ и схема группировок (§1.1, §1.5)`.
+
+---
+
+### Task 6: [2.3] Иерархия — каскад стратегий
+
+**Files:**
+- Create: `api/src/parser/hierarchy.py`
+- Test: `api/tests/unit/parser/test_hierarchy.py`, `api/tests/fixtures/test_hierarchy_real.py`
+
+**Interfaces:**
+```python
+@dataclass(frozen=True)
+class BodyRow: # строка данных до классификации
+ row: int; text: str; is_account: bool; account_rank: int | None # ранг = число сегментов кода (60 → 1, 60.01 → 2, 60.01.1 → 3)
+ bold: bool; indent: int; outline: int
+ sums: tuple[Decimal | None, ...] # шесть значений по ColumnMap (уже Decimal, см. Task 7 — здесь через numbers.to_money)
+@dataclass(frozen=True)
+class Levels: by_row: dict[int, int]; parent: dict[int, int | None]; strategy: str
+Strategy = Callable[[Sequence[BodyRow]], dict[int, int] | None]
+def strategy_outline(rows) -> dict|None # None, если < 2 различных уровней
+def strategy_indent(rows) -> dict|None # уровни = ранги различных отступов (0,1,2…); None, если < 2 различных
+def strategy_account_pattern(rows) -> dict|None # счёт: level = rank−1 относительно минимального ранга; не-счёт: level родителя+1, bold на уровне не-счёта → не глубже, последующие небольшие (не bold) → +1; None, если нет счетов
+def strategy_sums(rows) -> dict|None # жадно: строка — родитель последующих, пока Σ по шести колонкам не совпадёт; None, если не сошлось
+STRATEGIES: tuple[tuple[str, Strategy], ...] = (("outline", …), ("indent", …), ("account_pattern", …), ("sums", …))
+def is_consistent(rows, levels, total_row: int | None) -> bool # у каждой строки level>0 есть предыдущая с меньшим level; total (если есть) level 0; первая строка level 0
+def resolve_levels(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels # первый консистентный; иначе InvalidFormatError("иерархия строк не определена")
+def parents(rows, by_row) -> dict[int, int | None] # ближайшая предыдущая строка с меньшим уровнем
+```
+
+- [x] **Step 1: Тесты.** Юнит на `BodyRow`-списках: outline 0/1/2 → `outline`; все outline 0, отступы 0/2/4 → `indent`; без того и другого, коды 60/60.01/текст → `account_pattern` с уровнями 0/1/2; `sums` на трёх строках (родитель = сумма двух детей); неконсистентные уровни (ребёнок без родителя) отбрасываются, берётся следующая стратегия; «Итого» level 0 обязательно; `parents` для двух одноимённых контрагентов под разными субсчетами (Review Focus 3) различает родителей; дедлайн истёк → `ParseTimeoutError`. `test_hierarchy_real.py` (`fixtures`): все 66 — `strategy == "outline"`, `is_consistent`, у «Итого» level 0; `…66 за 2024.xls` — «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» (первое вхождение) имеет родителя-строку с текстом `66.03`.
+- [x] **Step 2–4:** падает → реализация → тесты, `make check`.
+- [x] **Step 5: Commit** `feat(parser): каскад стратегий иерархии строк (outline → indent → account_pattern → sums)`.
+
+---
+
+### Task 7: [2.4a] Числа
+
+**Files:**
+- Create: `api/src/parser/numbers.py`
+- Test: `api/tests/unit/parser/test_numbers.py`
+
+**Interfaces:**
+```python
+MONEY_Q = Decimal("0.01")
+class MoneyParse(NamedTuple): value: Decimal | None; warning: str | None
+def to_money(cell: Cell) -> MoneyParse
+# number → Decimal(str(float)).quantize(MONEY_Q, ROUND_HALF_UP); int → Decimal(int); bool → None + warning
+# text: убрать все пробелы (\s, , , ), заменить ',' → '.', '(x)' → '-x', '−'(U+2212) → '-';
+# прочерки {'-', '—', '–', ''} → None без warning; остаток не матчит ^-?\d+(\.\d+)?([eE][-+]?\d+)?$ → None + warning
+def inn_digits(cell: Cell) -> str | None # только цифры; длина 10 или 12 → строка, иначе None (число 3528052718.0 → "3528052718")
+```
+
+- [x] **Step 1: Тесты:** `1 234 567,89` → `1234567.89`; `(1 234,00)` → `-1234.00`; `—` → None; `8.577e+00` (текст) → `8.58`; float `0.1+0.2` → `0.30`; int `5` → `5.00`; `1234567.891` → `1234567.89`; `"abc"` → None + warning; `inn_digits` для `"3528052718"`, `3528052718.0`, `"ИНН 352819549855"`, `"123"` → None.
+- [x] **Step 2–4:** падает → реализация → тесты.
+- [x] **Step 5: Commit** `feat(parser): нормализация сумм и ИНН (Decimal, текстовые форматы)`.
+
+---
+
+### Task 8: [2.4b] Классификация строк
+
+**Files:**
+- Create: `api/src/parser/classify.py`
+- Test: `api/tests/unit/parser/test_classify.py`, `api/tests/fixtures/test_classify_real.py`
+
+**Interfaces:**
+```python
+ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.\d{1,2}){0,2})(?:,\s*(.*))?$") # единственная регулярка в коде: формат кода счёта — структура, не синоним
+def collect_body_rows(sheet: Sheet, header: TableHeader, rules: Rules, deadline: Deadline) -> tuple[list[BodyRow], int | None, list[str]]
+# строки от first_data_row до последней непустой; пропуск пустых и строк из пробелов (Review Focus 5) и повторов заголовка (is_header_repeat); последняя строка, матчащая rules.headers.total_row и имеющая суммы → total_row; суммы через numbers.to_money (warnings с row_number)
+@dataclass(frozen=True)
+class ClassifiedRow: body: BodyRow; level: int; parent: int | None; group_type: Literal[...]; group_name: str | None; account_code_raw: str | None; counterparty_name: str | None; counterparty_inn: str | None; data_type: DataType | None; extra: dict[str, str | None] | None
+def classify_rows(rows: Sequence[BodyRow], levels: Levels, schema: list[OsvGroup], header: TableHeader, sheet: Sheet, doc_data_type: DataType | None, total_row: int | None, rules: Rules) -> list[ClassifiedRow]
+```
+Правила: ACCOUNT по `ACCOUNT_RE` (`account_code_raw` = группа 1, как в файле); текущая ветка счёта наследуется детям (`account_code_raw` у не-счётных строк = код ближайшего предка-счёта); не-счёт — позиция = число не-счётных предков после последнего ACCOUNT → `schema[позиция]` (индекс 0 — ACCOUNT, поэтому первая не-счётная группа = `schema[1]`), выход за схему → OTHER; `group_name` = `OsvGroup.name`; COUNTERPARTY → `counterparty_name = text`, `counterparty_inn = inn_digits(cell(inn_col))`; `data_type`: текст матчит `data_types.row` или ∈ `currency_codes` (регистронезависимо) → CURRENCY/QUANTITY, иначе `doc_data_type` (`# ASSUMPTION(OQ-45)`); `total_row` → TOTAL с `level 0`, `parent None`; `extra` = `{имя: sheet.text_at(row, col) or None for col, имя in header.extra_cols}` или `None`, если все значения пусты.
+
+- [x] **Step 1: Тесты.** Юнит: 60 / 60.01 / контрагент / договор → ACCOUNT, ACCOUNT, COUNTERPARTY (`schema[1]`), CONTRACT; выход за схему → OTHER; «60.01, Расчеты с поставщиками» → `account_code_raw "60.01"`; «USD» и «Кол.» → CURRENCY/QUANTITY; ИНН-колонка; `extra` с КПП и `None` при пустых; строка счёта со всеми `null` суммами не падает и не попадает в `extra` дубликатами (Review Focus 1); пустые строки и `" "` пропущены (Review Focus 5); повтор заголовка пропущен. `test_classify_real.py` (`fixtures`): `schastl/2024-Q3/…62…xls` — «БП Аренда» DEPARTMENT, «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» COUNTERPARTY с 10-значным ИНН, «Ведомость в счет з/платы» COUNTERPARTY с `None`; `rb/2024-FY/ОСВ_66 за 2024.xls` — «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» `account_code_raw "66.03"`; у всех 66 ровно одна строка TOTAL.
+- [x] **Step 2–4:** падает → реализация → тесты, `make check`.
+- [x] **Step 5: Commit** `feat(parser): классификация строк по group_schema (§1.3)`.
+
+---
+
+### Task 9: [3.1] Контрольные суммы, статусы, оркестратор, настройки
+
+**Files:**
+- Create: `api/src/parser/checks.py`, `api/src/parser/osv.py`, `api/src/parser/deadline.py`
+- Modify: `api/src/parser/__init__.py` (`get_parser()` → `OsvParser(load_rules(), settings)`; `NullParser` остаётся для тестов), `api/src/core/config.py` (`PARSE_MAX_BYTES: PositiveInt = 50 * 1024 * 1024`, `PARSE_TIMEOUT_SECONDS: PositiveInt = 120`), `api/.env.example`, `api/.env.test`, `api/src/app.py` (передать настройки в `get_parser(settings)`)
+- Test: `api/tests/unit/parser/test_checks.py`, `api/tests/unit/parser/test_osv_parser.py`, `api/tests/fixtures/test_osv_property.py`; `tests/unit/test_null_parser.py` → переименовать в `test_parser_factory.py`
+
+**Interfaces:**
+```python
+# deadline.py
+class Deadline:
+ def __init__(self, seconds: float, clock: Callable[[], float] = time.monotonic)
+ def check(self, where: str) -> None # ParseTimeoutError(f"превышен лимит {seconds} с на шаге {where}")
+# checks.py
+TOLERANCE = Decimal("0.01")
+def balance_identity(rows: Sequence[ClassifiedRow]) -> list[int] # row_number нарушителей (null = 0)
+def parent_sums(rows: Sequence[ClassifiedRow]) -> list[int] # родители, у которых Σ детей того же data_type (кроме CURRENCY/QUANTITY-детей) ≠ своих сумм хотя бы в одной колонке
+def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool # TOTAL == первая строка ACCOUNT level 0 по шести колонкам; нет корня → True, если все суммы TOTAL null/0
+def run_checks(rows) -> tuple[OsvChecks, list[str]] # + тексты предупреждений с номерами строк
+# osv.py
+class OsvParser:
+ def __init__(self, rules: Rules, *, max_bytes: int, timeout_seconds: float, clock=time.monotonic) -> None
+ def parse(self, data: bytes, file_type: FileType) -> ParseOutcome
+ def parse_sheet(self, sheet: Sheet, deadline: Deadline) -> tuple[OsvDocument, list[str], bool] # документ, предупреждения, ok(инварианты)
+def get_parser(settings: Settings | None = None) -> Parser
+```
+`parse`: размер > max_bytes → `UNSUPPORTED_FORMAT`; `read_book` (ошибки → коды); листы: `classify_sheet` → OSV → `parse_sheet`, STATEMENT → warning `sheet= skipped: statement (slice 2b)`, OTHER → warning; ни одного OSV → `INVALID_DOCUMENT` «листов просмотрено N»; `InvalidFormatError` на листе → `error INVALID_FORMAT` всего файла с именем листа и шага; `ParseTimeoutError` → `TIMEOUT`; любое другое исключение → `RECOGNITION_FAILED` (одна строка `type: message`). Статус: все листы ok → `done`, иначе `needs_check`. `metrics = Metrics(rows=Σ строк данных без TOTAL, warnings=[...])`, первой записью по каждому листу — `sheet= hierarchy=`. `columns` документа = `ColumnMap.as_diagnostics()` + `{"inn": letter}` при наличии.
+
+- [x] **Step 1: Тесты.** `test_checks.py`: тождество с null; родитель ≠ Σ детей; CURRENCY-дети исключены; TOTAL vs корень; пустая ОСВ без корня → True. `test_osv_parser.py` (юнит, книги собираются openpyxl в памяти): мини-ОСВ → `done` с полными полями; испорченная сумма → `needs_check`, `checks` и warnings с номерами; лист без заголовка → `INVALID_FORMAT`; книга без ОСВ → `INVALID_DOCUMENT`; `data` на 1 байт больше лимита (через `max_bytes=10`) → `UNSUPPORTED_FORMAT`; дедлайн (clock-заглушка) → `TIMEOUT`; исключение в стратегии (monkeypatch) → `RECOGNITION_FAILED`; ББ-лист + ОСВ-лист → один документ + warning; результат проходит `RecognitionResult.model_validate` и JSON-схему v1.1. `test_parser_factory.py`: `get_parser()` — `OsvParser`, `NullParser` по-прежнему `NOT_IMPLEMENTED`. `test_osv_property.py` (`fixtures`, параметр `osv_file`): `status is DONE`; один документ; `checks.total_row_matches_root`; списки нарушений пусты; `header.account_code_raw == rows[0].account_code_raw` (корень); `period_quarter` задан; `metrics.rows > 0` кроме файла `…66 за 2 квартал 2025 г..xlsx` (0); разбор < 5 с; результат валиден по схеме v1.1.
+- [x] **Step 2–4:** падает → реализация → `pytest tests/unit/parser tests/fixtures -v`, `make check`.
+- [x] **Step 5: Commit** `feat(parser): контрольные суммы §1.4, статусы, OsvParser как парсер по умолчанию, лимиты`.
+
+---
+
+### Task 10: [3.2] Интеграция с воркером и CLI
+
+**Files:**
+- Create: `api/src/parser/cli.py` (`python -m src.parser.cli <файл> [--file-type XLSX]` → JSON результата в stdout, код возврата 0/1/2 по статусу)
+- Modify: `api/tests/unit/test_worker.py` (новый тест с реальным `OsvParser` и байтами `rb/2024-FY/ОСВ_66 за 2024.xls`, маркер `fixtures`), `api/tests/integration/test_roundtrip.py` (сценарий «Реальная ОСВ через воркер»: файл в MinIO, запрос `XLS`, конверт `done`, `result_schema_version "1.1"`, файл результата по схеме, `account_code_raw "66"`), `README.md` (раздел «Запуск на файле»)
+
+- [x] **Step 1:** тесты (юнит с `fixtures`, интеграция с `make up`) → падают (нет CLI / воркер с NullParser).
+- [x] **Step 2:** реализация CLI, `app.py` уже на `get_parser(settings)`; `make check`, `make test-integration` → PASS.
+- [x] **Step 3: Commit** `feat: реальный парсер в воркере, CLI разбора файла, интеграционный сценарий`.
+
+---
+
+### Task 11: [4.1] Синтетические фикстуры
+
+**Files:**
+- Create: `api/tests/synthetic/generate.py` (детерминированный генератор openpyxl; `python -m tests.synthetic.generate `), `api/tests/synthetic/__init__.py`, `api/tests/synthetic/test_synthetic_files.py`
+- Create (worktree `.wt/slice-2/fixtures`, ветка `feat/4-slice-2a-osv-parser`): `osv/synthetic/*.xlsx`, `osv/synthetic/*.xlsb` (один), `osv/synthetic/README.md`
+
+**Набор (имена файлов фиксированы, каждый — отдельный сценарий спеки):**
+| Файл | Что проверяет |
+|---|---|
+| `s01_variant2_header.xlsx` | шапка вариант 2, «Субконто», группировки через запятую |
+| `s02_no_outline_indent.xlsx` | уровни только отступами → `hierarchy=indent` |
+| `s03_no_outline_no_indent.xlsx` | уровни по кодам счетов и жирности → `account_pattern` |
+| `s04_currency_quantity_rows.xlsx` | строки «USD», «Кол.» под контрагентом |
+| `s05_text_numbers.xlsx` | суммы текстом «1 234 567,89», «(1 234,00)», «—» |
+| `s06_two_osv_and_other_sheet.xlsx` | листы «ОСВ 60», «Справка», «ОСВ 62» |
+| `s07_no_opening_collapsed_closing.xlsx` | без «начала», свёрнутое сальдо на конец |
+| `s08_extra_kpp_column.xlsx` | колонка КПП между ИНН и суммами → `extra` |
+| `s09_subaccount_names.xlsx` | «60.01, Расчеты с поставщиками» |
+| `s10_broken_sum.xlsx` | испорченная сумма → `needs_check` |
+| `s11_header_repeat.xlsx` | повтор заголовка внутри данных |
+| `s12_statement_and_osv.xlsx` | лист ББ + лист ОСВ → один документ + warning |
+| `s13_no_outline_indent.xlsb` | xlsb, сконвертирован вручную из `s02` (LibreOffice/Excel), закоммичен; `hierarchy=indent` |
+
+- [x] **Step 1:** тесты `test_synthetic_files.py` (параметр `synthetic_file`, маркер `fixtures`): таблица «файл → ожидания» (стратегия, статус, число документов, конкретные поля из сценариев спеки); для `s13` — результат равен `s02` кроме `locator`/warnings.
+- [x] **Step 2:** генератор; `python -m tests.synthetic.generate ../../fixtures/osv/synthetic` (из `api/`); проверить детерминированность (повторный запуск — файлы байт в байт). xlsb: конвертировать `s02` вручную; если инструмента на машине нет — записать в README fixtures и в ledger как `deferred` с инструкцией, тест `s13` помечается `skip` с причиной «нет файла» (без провала плана).
+- [x] **Step 3:** коммит в fixtures: `osv/synthetic: 12 синтетических ОСВ и xlsb (Срез 2a), README`; коммит здесь: `test: генератор синтетики и сценарии спеки на синтетических файлах`.
+
+---
+
+### Task 12: [4.2 + 4.3] Golden-снимки, линтер координат, покрытие словарей
+
+**Files:**
+- Create: `api/tests/golden_helpers.py`, `api/tests/fixtures/test_golden.py`, `api/scripts/golden_update.py`, `api/tests/unit/parser/test_no_coordinates.py`, `api/tests/fixtures/test_rules_coverage.py`
+- Modify: `Makefile` (`golden-update`, `test-fixtures`), `README.md`
+- Create (worktree fixtures): `golden/expected_osv_parse/**.json` (зеркало `osv/`, включая `synthetic/`)
+
+**Interfaces:**
+```python
+# golden_helpers.py
+VOLATILE = ("job_id", "file_id", ("service", "version"), ("metrics", "duration_ms"))
+def normalize_result(result: dict) -> dict # удалить volatile; порядок warnings и ключей не менять (он значим для diff)
+def golden_path(fixtures_root: Path, file: Path) -> Path # golden/expected_osv_parse/<относительный путь без расширения>.json
+def render(parser, file: Path) -> dict # как CLI: RecognitionResult с фиксированными job_id/file_id → to_wire → normalize
+# scripts/golden_update.py: для всех файлов пишет снимки, печатает unified diff изменившихся; флаг --check только сравнивает
+# test_no_coordinates.py: AST по src/parser/**/*.py кроме grid.py и readers/*; падает на: строковых литералах, матчащих ^[A-Z]{1,3}\d{0,7}$ длиной ≤ 4, литералах, содержащих «лист» (без регистра), сравнениях `ast.Compare` где одна сторона — Attribute/Name с именем row|row_number|r и другая — ast.Constant int; подписка Sheet.cells[(int, int)] константами
+# test_rules_coverage.py: прогоняет парсер по всем фикстурам+синтетике с Rules, обёрнутыми в счётчик срабатываний Pattern; каждая запись каждого словаря ≥ 1; сообщение — список несработавших
+```
+
+- [x] **Step 1:** тесты: `test_golden.py` (параметры `osv_file` и `synthetic_file`): снимок существует (иначе падение «нет снимка: make golden-update») и равен нормализованному результату (diff в сообщении); линтер — проверить, что намеренно подложенный файл с `"E9"` ловится (тест линтера на временном дереве); покрытие словарей.
+- [x] **Step 2:** реализация; первичная запись снимков `make golden-update`; **ручная сверка** трёх файлов в отчёте задачи: `rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx` (Итого 371 372 027.47 / 2 034 858 970.41 / 2 148 560 264.30 / 485 073 321.36 — opening_credit, turnover_debit, turnover_credit, closing_credit), `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls` (Итого 67 604 324.76 / 139 931 692.60 / 113 996 722.90 / 93 539 294.46 — opening_debit, turnover_debit, turnover_credit, closing_debit), `rb/2024-Q3/сч 583_3 кв 2024.xlsx` (корень `58.03`); несработавшие записи словарей — удалить или добавить синтетику (новый файл `s14_*` допустим, с обновлением Task 11 таблицы).
+- [x] **Step 3:** коммит в fixtures: `golden/expected_osv_parse: снимки парсера для 66 ОСВ и синтетики`; коммит здесь: `test: golden-снимки, линтер координат, покрытие словарей; make golden-update`.
+
+---
+
+### Task 13: [5.1] Документы и `make check-full`
+
+**Files:**
+- Modify: `README.md` (архитектура парсера по модулям, статусы и коды, словари и как добавить синоним, golden-процесс, лимиты, CLI), `CLAUDE.md` (раздел «Добавлено в Срезе 2a»: модули, правила расширения, линтер), `api/rules/README.md`, `openspec/changes/slice-2a-osv-parser/tasks.md` (все `[x]` кроме 5.2), `openspec/changes/slice-2a-osv-parser/design.md` (рулинги контроллера в таблицу решений)
+- Modify (worktree docs): `context/05-domain-rules.md` §1 (уточнения из спеки D-6: карта статусов, `hierarchy=<имя>`, TOTAL в rows, лимиты, имена словарей; §1.5 — `ASSUMPTION(OQ-02)`), `context/06-test-data.md` (синтетика s01–s13 и назначение, golden, xlsb), `context/09-delivery-plan.md` (Срез 2 → 2a–2d, 2a по факту), `context/07-open-questions.md` (OQ-81 закрыт; новые OQ: коды валют CURRENCY-строк; несколько ОСВ одного счёта в одной книге), `openspec/changes/slice-2a-osv-parser/tasks.md` (1.2, 2.1 → `[x]`)
+
+- [x] **Step 1:** таблица «требование спеки `osv-parsing` → сценарий → тест» в отчёте задачи; каждый сценарий имеет тест (иначе добавить).
+- [x] **Step 2:** документы по списку; `openspec validate --strict` в обоих репозиториях.
+- [x] **Step 3:** `make check-full` (compose поднят: `make up`; MinIO бакет `arm-ka` есть — проверить `test_s3_integration`, OQ-80) → всё зелёное, вывод в ledger; `pytest -m fixtures` → 66 + синтетика + golden зелёные.
+- [x] **Step 4:** коммиты: здесь `docs: README/CLAUDE.md Среза 2a, tasks.md`; в docs `docs: §1 по факту парсера, 06-test-data, план срезов 2a–2d, OQ`.
+
+---
+
+## Завершение
+
+Финальное ревью всей ветки (opus): спека ↔ код по 9 требованиям `osv-parsing`, инварианты (нет координат — помимо линтера глазами, нет `float` в домене, словари — данные), качество детекторов на граничных случаях (Review Focus 1–5), тесты. Находки `C-n`/`I-n`/`M-n` со статусами; `deferred` — в описание PR #6 и follow-up issue при закрытии.
diff --git a/openspec/changes/slice-2a-osv-parser/.openspec.yaml b/openspec/changes/slice-2a-osv-parser/.openspec.yaml
new file mode 100644
index 0000000..e3966d7
--- /dev/null
+++ b/openspec/changes/slice-2a-osv-parser/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-10-05
diff --git a/openspec/changes/slice-2a-osv-parser/design.md b/openspec/changes/slice-2a-osv-parser/design.md
new file mode 100644
index 0000000..ea004a5
--- /dev/null
+++ b/openspec/changes/slice-2a-osv-parser/design.md
@@ -0,0 +1,177 @@
+# Design: Срез 2a — парсер ОСВ
+
+## Context
+
+- Точка подключения — `api/src/parser/__init__.py:get_parser()`; воркер вызывает `parser.parse(data, file_type)` в
+ `asyncio.to_thread` и сам собирает конверт, пишет `results/{file_id}/{job_id}/result.json` (`worker.py`).
+ `ParseOutcome` требует непустые `documents` при `done|needs_check` и непустую `error` при `error`.
+- Контракт v1.0: `osvDocument{locator, header, group_schema, columns, rows[], checks}`; у строки нет места для
+ нераспознанных колонок — §1.2 требует `extra`. В v1.1 (docs change) добавляется `rows[].extra: object`.
+- Реальные файлы (66, `../fixtures/osv/**`): вариант 1 шапки, заголовок таблицы 2–3 строки с merged, колонки сумм
+ плавают (E/H…Q у РБ, G/J…S у Счастливочной), ИНН в отдельной колонке у одной организации, outline level есть и в xls, и в
+ xlsx (xls через `xlrd.rowinfo_map`), «Итого» последней строкой level 0, мусор правее таблицы (экспоненты, числа в T/U),
+ пустая ОСВ (66 за 2 кв. 2025), ОСВ по субсчёту 58.03, физлица/ИП как контрагенты. Нет: вариант 2 шапки, Вал./Кол.,
+ файлы без outline, несколько листов, xlsb, текстовые числа.
+- Платформа: Python 3.11; `openpyxl` без `read_only` (иначе нет outline level), `xlrd` с `formatting_info=True`,
+ `pyxlsb` без outline level. Размеры до ~330 КБ и ~1000 строк; 2280 merged-диапазонов на файл.
+
+## Goals / Non-Goals
+
+**Goals:** все 66 реальных файлов и вся синтетика дают `done` с выполненными инвариантами §1.4 без правил под файл;
+в `src/parser/` нет литералов координат (линтер); каждый шаг обнаружения тестируем отдельно; диагностика говорит,
+какой шаг не уверен. **Non-Goals:** см. proposal.
+
+## Decisions
+
+### D-1. Единая модель листа `Sheet`, читатели — адаптеры
+`grid.py`: `Cell(value: str | Decimal | date | datetime | bool | None, kind: Literal["empty","text","number","date","bool"],
+bold: bool, indent: int)`, `Sheet(name, cells: Mapping[(row, col), Cell], merged: tuple[MergedRange, ...], row_outline:
+Mapping[int, int], n_rows, n_cols)`. Хранятся только непустые ячейки; ячейки из одних пробелов не хранятся в индексе строк.
+Методы: `cell(r, c)`, `text_at(r, c)` (внутри merged — текст origin), `merged_origin(r, c)`, `merged_span(r, c)`,
+`row_cells(r)` (непустые `(col, cell)` по возрастанию колонки), `is_row_empty(r)`, `outline(r)`. Индексы 1-based, как в
+Excel, для диагностики (`row_number` контракта). Читатели (`readers/xlsx.py`, `xls.py`, `xlsb.py`) возвращают
+`tuple[Sheet, ...]`, а `read_book(data, file_type) -> (sheets, warnings)` выбирает читателя по сигнатуре байтов; числа —
+`Decimal(str(value))` без квантования (квантование до 2 знаков только для сумм, в `numbers.py`).
+*Альтернатива* — работать напрямую с объектами openpyxl/xlrd: три разных API в детекторах, невозможно тестировать на
+синтетических сетках. *Цена ошибки:* модель расширяется полями, адаптеры тонкие.
+
+### D-2. Детекторы — чистые функции над `Sheet`, результат — индексы, а не буквы
+`detect.classify_sheet(sheet, rules) -> SheetKind` (ОСВ / форма ББ-ОФР / прочее); `detect.find_table_header(sheet, rules)
+-> TableHeader(first_row, last_row, columns: ColumnMap(шесть индексов; `None` у «начала», если его нет), text_col,
+inn_col | None, right_edge, extra_cols, group_label_rows)`. Поиск групп — по тексту ячеек в пределах 1–4 подряд идущих
+строк с учётом merged (значение берётся из origin), «Дебет/Кредит» ищутся в следующей строке внутри span группы. **Строка
+групп обязана содержать «обороты» и «сальдо на конец»; отсутствовать может только «начало»** (R-12, design №22):
+кандидат без них пропускается, при отсутствии подходящего — `INVALID_FORMAT` с названием недостающей группы. Одна колонка
+в группе без Дебет/Кредит → свёрнутое сальдо (знак = сторона) с предупреждением. Всё правее `right_edge` игнорируется
+(§1.2). В `columns` контракта пишутся буквы колонок **только как диагностика** (функция `column_letter(index)` в
+`grid.py`), код парсера по буквам ничего не ищет.
+
+### D-3. Каскад стратегий иерархии
+`hierarchy.levels(sheet, body_rows, header) -> Levels(by_row: dict[int,int], strategy: str)`. Порядок: `outline`
+(если ≥ 2 различных уровней среди строк данных) → `indent` (`Cell.indent` или ведущие пробелы, ≥ 2 различных) →
+`account_pattern` (строка-счёт ранга по числу сегментов кода, не-счёт = ранг последнего счёта + 1 + глубина по жирности)
+→ `sums` (строка — родитель следующих, пока сумма детей не совпадёт по шести колонкам). Первая стратегия, давшая
+непротиворечивое дерево (каждая строка имеет родителя с меньшим уровнем), побеждает; имя — в `metrics.warnings` как
+`hierarchy=` всегда (чтобы golden фиксировал стратегию). Строка «Итого» стратегиям **не передаётся**: она всегда
+уровень 0 без родителя (R-14, design №24). Если строк данных (кроме «Итого») меньше двух — стратегия `trivial` (дерево из
+корней; пустая ОСВ, R-13, design №23); `outline` у всех файлов со строками данных, `trivial` — у пустых. *Цена ошибки:* порядок
+стратегий меняется в одном списке.
+
+### D-4. Классификация и `group_schema`
+`header.group_schema(sheet, table_header, rules)`: вариант 1 — строки первой колонки блока заголовка сверху вниз,
+первая всегда ACCOUNT; вариант 2 — «субконто: A, B» через запятую после первого ACCOUNT; ячейка `ИНН` в строке
+группировки → `has_inn`. `classify.rows(...)`: счёт по `ACCOUNT_RE` (две-три цифры и до двух сегментов субсчёта, где сегмент — `\d{1,2}` или 1–3 ЗАГЛАВНЫЕ буквы без
+IGNORECASE: «76.АВ», R-16; после запятой допустим `\s*`, R-18), нормализация кода (`60.1 →
+60.01`) в `account_code_raw` остаётся как в файле (контракт), нормализованный код — забота ядра; не-счёт → группа по
+позиции (количество не-счётных предков после последнего ACCOUNT); для COUNTERPARTY `counterparty_name = row_value`,
+`counterparty_inn` — только из колонки ИНН (цифры из текстовой ячейки, результат при 10 или 12 цифрах, иначе `null`; ИНН
+внутри текста строки не извлекается — R-15, R-17); строки «Вал.», «Кол.», трёхбуквенные коды валют → `group_type` OTHER,
+`data_type` CURRENCY/QUANTITY, без позиции в схеме и без `counterparty_*` (`account_code_raw` наследуется от ветки, R-19);
+их суммы остаются в шести полях, `data_type` лишь помечает строку, и в инвариантах §1.4 такие строки исключаются. Последняя строка level 0 с текстом из словаря «Итого» → `TOTAL`.
+
+### D-5. Числа
+`numbers.to_money(cell) -> Decimal | None`: число → `Decimal(str(value)).quantize(0.01)`; текст → убрать пробелы (в т.ч.
+NBSP, узкие), запятую → точку, `(x)` → `-x`, прочерки/пусто → `None`; нечисловой текст в колонке сумм → `None` + предупреждение
+с `row_number`. Экспоненциальная запись принимается (`8.577e+00`), но такие ячейки вне `right_edge` в суммы не попадают.
+
+### D-6. Статусы
+`osv.parse`: нет листов ОСВ → `error INVALID_DOCUMENT`; лист ОСВ без заголовка таблицы или без колонок сумм →
+`error INVALID_FORMAT` (с указанием шага); книга не открывается → `UNREADABLE`; расширение/сигнатура не поддерживаются →
+`UNSUPPORTED_FORMAT`; размер > `PARSE_MAX_BYTES` → `UNSUPPORTED_FORMAT` с текстом про лимит; дедлайн превышен → `TIMEOUT`;
+любое непредвиденное исключение → `RECOGNITION_FAILED`. Инварианты §1.4 нарушены хотя бы на одном листе → `needs_check`
+всего результата, номера строк в `checks`, причины в `warnings`. Сверка «сумма детей = родитель»: обороты — по колонкам;
+сальдо — по колонкам либо по нетто, если у родителя оно свёрнуто (R-21, design №31). «Итого» не найдена — предупреждение и
+`total_row_matches_root = false` (R-20, design №30). Пустая ОСВ — `done` с `checks.total_row_matches_root = true` (R-13). Несколько листов ОСВ → несколько документов, лист с
+признаками ББ/ОФР (§12 п. 1) → пропуск с предупреждением `sheet= skipped: statement (slice 2b)`.
+
+### D-7. Словари — pydantic-модели из YAML, загружаются один раз
+`rules.py: load_rules(path) -> Rules` (frozen); кэша в `get_parser()` нет: парсер создаётся один раз при старте
+приложения, ошибка словаря — падение старта. Регулярки компилируются с `re.IGNORECASE`
+после нормализации «ё→е» и схлопывания пробелов/переносов обеих сторон. Тест `test_rules_coverage`: каждая запись
+словаря матчится хотя бы одной фикстурой или синтетикой (иначе запись мёртвая — либо удалить, либо добавить синтетику).
+
+### D-8. Лимиты и таймаут без убийства потока
+`Deadline(monotonic + PARSE_TIMEOUT_SECONDS)` проверяется между листами, между стратегиями и каждые 500 строк; превышение →
+`TIMEOUT`. Это не защищает от зависания внутри openpyxl при чтении — там работает лимит размера. Значения по умолчанию
+50 МиБ / 120 с — ниже `reply_deadline` ядра (5 мин).
+
+### D-9. Контракт 1.1 и копии
+`CONTRACT_VERSION = "1.1"`; `OsvRow.extra: dict[str, str | None]` (ключ — заголовок колонки как в файле или буква, если
+заголовка нет; значение — текст ячейки). Копия схемы в `api/contracts/` обновляется из `../docs/contracts/` в той же
+задаче (тест идентичности). arm-core получает копию отдельным PR без логики (ядро пока результат не читает).
+
+### D-10. Тесты: инварианты вместо оракула, golden как регрессия
+Property-тест параметризован по всем файлам `../fixtures/osv/**` (`@pytest.mark.fixtures`, хелпер `fixtures_helpers.py` с
+`ARM_KA_FIXTURES`): статус ≠ `error`; `checks.total_row_matches_root`; нет нарушений §1.4; `header.account_code_raw`
+равен коду корневой строки; `period_quarter` распознан; хотя бы один документ. Golden: `result.json` без `job_id`,
+`file_id`, `service.version`, `metrics.duration_ms`; каталог зеркалит `fixtures/osv/`; обновление — `make golden-update`
+(перезапись + вывод diff), отдельный коммит в fixtures. Синтетика: `tests/synthetic/generate.py` детерминированно пишет
+xlsx в `../fixtures/osv/synthetic/`; xlsb — один файл `s13`, собранный тестовым писателем BIFF12 (`tests/synthetic/xlsb_writer.py`; LibreOffice нет, R-9),
+а не сконвертированный в Excel: совместимость с настоящим xlsb не доказана (O-7). Линтер `test_no_coordinates.py`: AST `src/parser/**` кроме `grid.py` и `readers/` — запрещены строковые
+литералы `^[A-Z]{1,3}\d*$` длиной ≤ 4, литералы с «Лист», сравнения `row == ` / индексация `[]` по `Sheet`.
+
+## Risks / Trade-offs
+
+- [`openpyxl` без `read_only` на большом файле медленный/прожорливый] → лимит 50 МиБ; реальные файлы ≤ 330 КБ.
+- [Каскад выберет не ту стратегию] → контрольные суммы §1.4 переводят в `needs_check`, стратегия видна в warnings.
+- [Шапка варианта 2 и Вал./Кол. проверяются только синтетикой] → первая реальная ОСВ другого клиента добавляется как
+ фикстура; принцип «новый формат = фикстура + синоним».
+- [xlsb без outline level] → для xlsb работает каскад с `indent`/`account_pattern`/`sums`; тест на синтетическом xlsb.
+- [Контракт 1.1 ломает побайтовую идентичность копии в arm-core до его PR] → PR в arm-core — в тот же день, тест там
+ пропускается только при недоступном docs, поэтому порядок merge: docs → tabular-parser → arm-core.
+
+## Migration Plan
+
+Не требуется: сервис без состояния. После merge — `make venv` (новые зависимости), образ пересобирается.
+
+## Open Questions
+
+Не блокируют: OQ-02 (нарастающие по данным — ядро), OQ-45 (`NU` в ядре), OQ-01 (хранение «Итого»), OQ-73 (дедуп по
+attempt), OQ-81 (частично закрыт: в tabular-parser `ARM_KA_FIXTURES` поддержан хелпером фикстур, `scan-gateway` — follow-up). Новые вопросы внесены в `07-open-questions.md` задачей 13
+(OQ-98…OQ-105: коды валют CURRENCY-строк, несколько ОСВ одного счёта в книге, ИНН внутри текста строки и «ИНН/КПП»,
+числа с «+»/«.5»/«5.», xlsb из Excel, буквенный первый сегмент счёта, «Валюта» в строке детализации, подтверждение
+сверки по нетто).
+
+## Decisions made without discussion
+
+| № | Дата | Решение | Обоснование | Цена ошибки |
+|---|---|---|---|---|
+| 1 | 2026-10-05 | D-1: единая модель `Sheet`, читатели-адаптеры | три библиотеки, один набор детекторов, тесты на синтетических сетках | расширить модель |
+| 2 | 2026-10-05 | D-2: буквы колонок только в диагностике `columns`, детекторы работают с индексами | §1.0, DoD «нет литералов координат» | нет |
+| 3 | 2026-10-05 | D-3: порядок стратегий outline → indent → account_pattern → sums; стратегия всегда в warnings | §1.0 п. 5; golden фиксирует стратегию | переставить список |
+| 4 | 2026-10-05 | D-4: `account_code_raw` как в файле, нормализация кода — в ядре | контракт v1.0 так определяет поле | добавить поле в контракт |
+| 5 | 2026-10-05 | D-6: карта статусов/кодов (INVALID_DOCUMENT без листов ОСВ, INVALID_FORMAT без заголовка, UNSUPPORTED_FORMAT при лимите размера) | enum контракта фиксирован | переназначить код |
+| 6 | 2026-10-05 | D-8: таймаут кооперативный (между шагами), лимит 50 МиБ / 120 с | `to_thread` нельзя прервать (worker.py) | числа в Settings |
+| 7 | 2026-10-05 | D-9: контракт MINOR 1.1 с `rows[].extra`; `CONTRACT_VERSION = "1.1"` | §1.2 «не терять» колонки; обратно совместимо | откатить до 1.0, потеря extra |
+| 8 | 2026-10-05 | D-10: golden без volatile-полей, обновление `make golden-update`, xlsb вручную один раз | нет LibreOffice в проверке | генерировать xlsb иначе |
+| 9 | 2026-10-05 | Лист с признаками ББ/ОФР пропускается с предупреждением, а не `NOT_IMPLEMENTED` на весь файл | файл с ОСВ и формой должен отдать ОСВ | вернуть ошибку |
+| 10 | 2026-10-05 | ASSUMPTION(OQ-02/45/01): `is_cumulative` по тексту; `NU` как есть; «Итого» строкой TOTAL | парсер не видит других файлов и БД | правка ядра |
+| 11 | 2026-10-05 | R-1: свёрнутое (одноколоночное) сальдо раскладывается по знаку в `classify.collect_body_rows` (плюс → дебет, минус → кредит), одно предупреждение «свёрнутое сальдо» на лист | сценарий спеки требует, ни одна задача плана не владела | перенести в `osv.py` |
+| 12 | 2026-10-05 | R-2: fixtures-тест иерархии на реальных файлах пишется в задаче 8 (на `collect_body_rows`), в задаче 6 — только unit | `BodyRow` строится из классификатора, который появляется в 8 | дефекты стратегий на реальных файлах всплывут позже |
+| 13 | 2026-10-05 | R-3: `deadline.py` создаётся в задаче 6, не 9 | `resolve_levels` и `collect_body_rows` принимают `Deadline` раньше задачи 9 | нет |
+| 14 | 2026-10-05 | R-4: линтер координат запрещает литерал, целиком совпадающий с именем листа по умолчанию (`(?i)^(лист\|sheet)[\s_]*\d*$`), а не любой литерал со словом «лист» | D-6 требует сообщений вида «лист ОСВ без заголовка» | пропустит имя листа внутри длинной строки |
+| 15 | 2026-10-05 | R-5: линтер не трогает константы в аннотациях и значения enum/Literal контракта (вычисляются из `src.contract`) | значения контракта (BU, Q1, XLS…) — структура, не координаты | буква колонки, равная значению enum, проскочит |
+| 16 | 2026-10-05 | R-6: в `data_types.yaml` добавлен `currency_aliases` (RUB → руб/рубл/rub), код `RUB` не литерал | синонимы — данные | переименование поля |
+| 17 | 2026-10-05 | R-7: AST-линтер создаётся в задаче 3, не в 12 | ловить нарушения по мере появления кода | задача 3 крупнее |
+| 18 | 2026-10-05 | R-8: тест сценария «Новый синоним» (tmp-копия словарей + «Нач. остаток») в задаче 9; в штатном словаре этого синонима нет | сценарий спеки без теста в плане | нет |
+| 19 | 2026-10-05 | R-9: xlsb — допускается тестовый минимальный писатель BIFF12 (нет LibreOffice), иначе `deferred` + skip | сценарий спеки XLSB | слабее доказательство совместимости с xlsb из Excel |
+| 20 | 2026-10-05 | R-10: непроверенные числовые утверждения плана о реальных файлах проверяет исполнитель; неверное — правится общим правилом, без таблицы исключений | фикстуры — не оракул | нет |
+| 21 | 2026-10-05 | R-11: расхождения формулировок D-1 и tasks.md 1.3 с построенным API (`read_book -> (sheets, warnings)`, `bool` kind, методы Sheet) правятся в задаче 13; тест «три читателя дают одинаковые ячейки» заменён тестами по читателю + реальные файлы (нет писателей xls/xlsb) | план — детальный контракт, спека описывала ранний эскиз | расхождение значений ячеек между читателями проявится только на golden |
+| 22 | 2026-10-05 | R-12: строка групп заголовка ОСВ обязана содержать «обороты» и «сальдо на конец» (отсутствовать может только «начало»); иначе строка пропускается, а при отсутствии подходящей — INVALID_FORMAT с названием недостающей группы | спека допускает отсутствие только «начала»; «≥ 2 из 3» из плана принимало заголовок без оборотов/конца и ложный заголовок из преамбулы | файл без «конца» отдаст INVALID_FORMAT вместо частичного разбора |
+| 23 | 2026-10-05 | R-13: пустая ОСВ (кроме «Итого» строк данных нет) или с единственной строкой данных — штатный случай: `hierarchy=trivial` (код: меньше двух строк данных); TOTAL = последняя непустая строка уровня 0 с текстом «Итого», суммы не требуются; утверждения плана «у всех 66 outline», «rows>0 кроме одного файла» обобщены (outline — для файлов со строками данных; пустые определяются свойством, а не списком; в фикстурах таких два: schastl 58 и 66, 2 кв 2025) | спека требует сценарий «Пустая ОСВ»; план предполагал один такой файл | пустая ОСВ дала бы INVALID_FORMAT |
+| 24 | 2026-10-05 | R-14: «Итого» не передаётся стратегиям иерархии, всегда уровень 0, родитель None (для всех четырёх стратегий) | иначе `account_pattern` вкладывает «Итого» в последний счёт и сценарий «без outline и отступов» невозможен; для outline/indent теряется слабая проверка согласованности | outline/indent примут лист с «Итого» на ненулевом уровне |
+| 25 | 2026-10-05 | R-15: ИНН из текстовой ячейки — берутся цифры («ИНН 352819549855» → `352819549855`), результат только при 10 или 12 цифрах, иначе null; для числовых ячеек — дополнение ведущим нулём (T4.3) | спека: «только цифры длиной 10 или 12 из колонки ИНН»; тест-кейс брифа Task 7 | контрагент с текстовым префиксом получит null-ИНН |
+| 26 | 2026-10-05 | R-16 (уточнён по ревью): сегмент субсчёта в `ACCOUNT_RE` — `\d{1,2}` или 1–3 ЗАГЛАВНЫЕ буквы кириллицы (с Ё) / латиницы, без IGNORECASE (цифровые сегменты остаются 1–2 знака: «60.001» — не счёт): «76.АВ», «76.ВА», «76.ОТ» — строки ACCOUNT; ранг = число сегментов | в 8 из 66 реальных файлов (РБ 76, schastl 76 за 2024 Q1–Q3) такие строки иначе становятся COUNTERPARTY и сдвигают тип детей; регулярка спеки с `\.\d{1,2}` реальным данным не соответствует | контрагент с именем вида «76.АВ» примут за счёт (маловероятно) |
+| 27 | 2026-10-05 | R-17: `counterparty_inn` — только из колонки ИНН (docs §1.3), иначе null; ИНН внутри текста строки («Полиграф, 3528315710», 20 строк schastl 76) парсер не извлекает, `counterparty_name` = весь `row_value`; пример контракта `result-osv-needs-check.json` приводится в соответствие (задача 13); вопрос про ИНН из имени — в 07-open-questions | docs §1.3 и спека: «только цифры из колонки ИНН, иначе null» | arm-core сам разбирает ИНН из имён |
+| 28 | 2026-10-05 | R-18: после запятой в `ACCOUNT_RE` допустим `\s*` (спека: `\s`); текст спеки правится в задаче 13 | допуск «60,Расчеты» без пробела безвреден; в фикстурах не встречается | «60,Расчеты» примут за счёт |
+| 29 | 2026-10-05 | R-19: строки «Вал.»/«Кол.»/код валюты — `group_type` OTHER, `data_type` CURRENCY/QUANTITY, не занимают позицию в схеме и НЕ наследуют `counterparty_name/inn` (`account_code_raw` наследуется от ветки); пример контракта правится в задаче 13 | поля контрагента — только для строк COUNTERPARTY (спека); docs §1.3 | consumers ядра ищут контрагента по родителю |
+| 30 | 2026-10-05 | R-20: если строка «Итого» не найдена — предупреждение «строка «Итого» не найдена» и `checks.total_row_matches_root = false` (→ needs_check) | сверку с корнем выполнить нельзя; консервативно — на проверку аналитику | файл без «Итого» всегда needs_check |
+| 31 | 2026-10-05 | R-21: сверка «сумма детей = родитель» (§1.4): обороты — по колонкам; сальдо — по колонкам ЛИБО, если у родителя сальдо свёрнуто (не более одной ненулевой стороны), по нетто «дебет − кредит» | 1С показывает счёт нетто (62 = 16,1 млн Дт), а субсчета развёрнуто (62.01 = 40,2 млн Дт, 62.02 = 24,1 млн Кт): 32 из 66 реальных файлов не сходились по шести колонкам только из-за этого; все 62 родителя проверены — обороты и нетто сходятся; негативы закреплены тестами; текст §1.4 и спеки правится в задаче 13 | перестановка сторон при совпавшем нетто родителя не ловится этой проверкой (ловят тождество строки и «Итого = корень») |
+| 32 | 2026-10-05 | R-22: тест покрытия словарей считает записи распознавания (паттерны osv_headers/osv_groups/periods/data_types); перечень кодов валют ISO (`currency_codes`) исключён — это список значений, а не синонимы; несработавшие паттерны удаляются из YAML или покрываются новым синтетическим файлом s14_* | список ISO нельзя навязать фикстурам | мёртвые коды валют остаются в списке |
+| 33 | 2026-10-05 | O-7: `s13.xlsb` собран тестовым писателем BIFF12, а не Excel; совместимость с настоящим xlsb не доказана, замена — по инструкции в README fixtures | R-9, нет LibreOffice/Excel на машине | чтение xlsb из Excel может отличаться от проверенного; проявится на первом реальном xlsb |
+| 34 | 2026-10-05 | O-8 (T12.1): строка детализации варианта 2 с группировкой «Валюта» совпадает с меткой валюты шапки и даёт ложное предупреждение «валюта «» не распознана» (в снимке s16); `currency = null` в обоих случаях; дефект принят, не исправлен | малое влияние, правка требует различать область шапки и строку детализации | ложное предупреждение у ОСВ с группировкой «Валюта» |
+| 35 | 2026-10-05 | R-23: чтение xlsx ограничено: (а) сумма несжатых размеров записей zip ≤ 10 × PARSE_MAX_BYTES, (б) до openpyxl сканируются `` и суммарная площадь merged-диапазонов ограничена именованной константой, иначе UNSUPPORTED_FORMAT с текстом лимита, (в) `deadline.check` в циклах detect; `defusedxml` не добавляется (новая зависимость — решение платформы) → follow-up | финальное ревью I-1: файл 4,9 КБ с одним mergeCell A1:Z200000 даёт 24 с и 1,4 ГБ; воркер не прервать | прочие XML-бомбы до внедрения defusedxml возможны |
+| 36 | 2026-10-05 | R-24: значениям-основанное определение колонки ИНН не дополняет 9/11-значные числа нулём (дополнение — только при извлечении значения из колонки, выбранной по заголовку или по строгому правилу 80 % из 10/12 цифр) | финальное ревью M-3: числовая колонка КПП (9 цифр) молча принималась за ИНН (counterparty_inn = «0»+КПП) | числовая колонка ИНН с потерянными нулями без заголовка «ИНН» не определится автоматически |
+| 37 | 2026-10-05 | R-25: вторая и последняя волна правок по I-1(б): предсканирование merge-диапазонов заменяется потоковым разбором `xml.parsers.expat` по ВСЕМ `.xml`-частям архива (namespace-aware, charref, кавычки), раннее прерывание при превышении площади, битый XML → UNREADABLE; после-чтение проверка merged считает только диапазоны, которые grid разворачивает (≤ порога индексации); остаточные риски (объём ячеек, sharedStrings/styles, entity expansion) — документируются, лимиты памяти контейнера/defusedxml — решение платформы | регулярочный скан обходился четырьмя способами из файла < 5 КБ (24 с, 1,4 ГБ) | бомба по числу ячеек (5 МБ, 2 млн ячеек → 13 с, 1,5 ГБ) и entity expansion остаются до решений платформы |
+| 38 | 2026-10-05 | R-26: третьей волны правок нет; предсканирование merge-диапазонов — эшелон защиты, а НЕ граница безопасности: закрывает обычную бомбу и все найденные стандартные обходы, но намеренно собранный файл (лист в части без `.xml` + ≥ 64 байт ведущих пробелов, без XML-декларации) доходит до openpyxl (27 с / 1,4 ГБ); настоящая граница — лимит памяти контейнера и/или подпроцесс с RLIMIT (решение платформы, OQ-108). Первый follow-up: убрать «sniff», лениво скармливать expat каждую не-каталожную часть + тест; ограничить число больших merged-диапазонов (xls) | ревью финальной волны, раунд 2 | специально собранный файл занимает воркер ~30 с / 1,4 ГБ до follow-up или лимита памяти платформы; файлы приходят от аутентифицированных пользователей внутренней ИС |
+| 39 | 2026-10-05 | I-1(б) закрыт: sniff убран, expat по всем частям; потолок крупных merged xls. Предскан xlsx отдаёт `xml.parsers.expat` КАЖДУЮ некаталожную запись архива без отбора по имени или первым байтам (части `*.xml` — строго, битый XML → UNREADABLE; остальные — мягко, ошибка разбора заканчивает скан части без отказа файла). После чтения любого формата число крупных merged-диапазонов листа (площадь > `grid.MAX_INDEXED_AREA` = 10 000, индекс их не раскладывает) — не больше `LARGE_MERGED_COUNT_LIMIT` = 1000, иначе UNSUPPORTED_FORMAT с текстом лимита. «Крупный» взят по существующему порогу индекса, а не по запасному «≥ 1000 ячеек / потолок 10 000», потолок — по предложению ревью (≤ 1000) | follow-up R-26 (ревью финальной волны, раунд 2): crafted-файл 1,9 КБ (лист в `xl/worksheets/sheet1.dat`, 64 пробела, без декларации) читался > 3 с (ревьюер: 27 с / 1,4 ГБ), после — отказ < 1 мс; 100 000 крупных диапазонов xls — ≈ 3,2 мс на обращение к `merged_span`, при 1000 — ≈ 31 мкс | расхождение expat скана и разбора openpyxl на части не `*.xml` (скан остановился раньше) вернёт обход — известных нет; законная книга с > 1000 диапазонами > 10 000 ячеек на листе отвергнется UNSUPPORTED_FORMAT (в реальных книгах — единицы); предскан по-прежнему не граница безопасности (OQ-108) |
diff --git a/openspec/changes/slice-2a-osv-parser/proposal.md b/openspec/changes/slice-2a-osv-parser/proposal.md
new file mode 100644
index 0000000..e2572cd
--- /dev/null
+++ b/openspec/changes/slice-2a-osv-parser/proposal.md
@@ -0,0 +1,64 @@
+# Proposal: Срез 2a — парсер ОСВ из XLS/XLSX/XLSB (динамическое обнаружение структуры §1)
+
+**Срез:** 2 «Загрузка файлов + табличный парсер» (`../docs/context/09-delivery-plan.md`), первый подсрез по решению
+владельца 05.10.2026: **только парсер ОСВ**. ББ/ОФР из Excel (§12), загрузка файлов и маппинг результата в ядре, UI —
+следующие подсрезы. **Репозиторий:** arm-tabular-parser. **Issue:** RSHB/arm-tabular-parser#5.
+**Связанные change'и:** `../docs/openspec/changes/slice-2a-osv-parser` (контракт v1.1, §1, OQ; RSHB/arm-ka-docs#9),
+фикстуры RSHB/arm-ka-fixtures#4 (синтетика, золотые снимки).
+
+## Why
+
+После Среза 0 сервис принимает задания и отвечает `NOT_IMPLEMENTED`. Без разбора ОСВ невозможны ни сводная ОСВ, ни
+ВГО, ни КФО — это самый рискованный блок системы (§1.0: ОСВ приходят из разных конфигураций 1С и других систем, и
+«ни одна буква колонки, ни один номер строки, ни одно имя листа не являются контрактом»). В fixtures есть 66 реальных
+ОСВ двух организаций (xls и xlsx, 2 и 3 строки заголовка, плавающие колонки, иерархия до 4 уровней, мусор правее
+таблицы, пустая ОСВ), на которых парсер обязан проходить без единого правила под файл.
+
+## What Changes
+
+- **Парсер ОСВ** `api/src/parser/`: единая модель листа (`grid.py`), читатели xlsx/xls/xlsb, детекторы структуры по
+ содержимому (`detect.py`), шапка и `group_schema` (`header.py`), каскад стратегий иерархии (`hierarchy.py`),
+ классификация строк (`classify.py`), нормализация чисел в `Decimal` (`numbers.py`), контрольные суммы §1.4
+ (`checks.py`), оркестратор (`osv.py`). `get_parser()` возвращает реальную реализацию; воркер, NATS и S3 без изменений.
+- **Словари** `api/rules/{osv_headers,osv_groups,periods,data_types}.yaml` с pydantic-валидацией при старте и тестом
+ «каждая запись срабатывает хотя бы раз».
+- **Зависимости** `api/requirements.txt`: `openpyxl==3.1.5`, `xlrd==2.0.2`, `pyxlsb==1.0.10`.
+- **Настройки** `PARSE_MAX_BYTES` (50 МиБ), `PARSE_TIMEOUT_SECONDS` (120) — проверка дедлайна между шагами, без
+ убийства потока.
+- **Контракт v1.1**: парсер пишет `schema_version = "1.1"` и заполняет `rows[].extra` (нераспознанные колонки, §1.2);
+ копия схемы в `api/contracts/` обновляется вместе с источником в `../docs/contracts/`.
+- **Тесты**: unit по модулям на синтетических сетках; property-тест инвариантов §1.4 на всех `../fixtures/osv/**`;
+ золотые снимки `../fixtures/golden/expected_osv_parse/`; синтетические файлы `../fixtures/osv/synthetic/` из
+ детерминированного генератора; тест-линтер «нет координат в `src/parser/`»; хелпер `ARM_KA_FIXTURES` (OQ-81).
+- **Документы**: README (как запустить на файле, коды ошибок, словари), CLAUDE.md, спека `osv-parsing`.
+
+## Capabilities
+
+### New Capabilities
+- `osv-parsing`: разбор ОСВ из Excel по §1 — обнаружение листов, заголовка и колонок по содержимому, шапка,
+ иерархия, классификация, числа, контрольные суммы, статусы и диагностика, словари, лимиты.
+
+### Modified Capabilities
+- `tabular-recognition-service`: требования «Парсер — заменяемый интерфейс» и «Результат по контракту» — реализация по
+ умолчанию разбирает ОСВ, листы ББ/ОФР пропускаются с предупреждением (§12, следующий подсрез), `NOT_IMPLEMENTED`
+ остаётся только у заглушки `NullParser` в тестах воркера; результат пишется с `schema_version 1.1`.
+
+## Impact
+
+- Код: `api/src/parser/**` (новое), `api/src/core/config.py` (две настройки), `api/src/contract/common.py`
+ (`CONTRACT_VERSION = "1.1"`), `api/contracts/recognition-result.schema.json` (копия v1.1), `api/rules/*.yaml`,
+ `api/requirements.txt`, тесты.
+- Таблицы DBML: не затрагиваются (сервис без БД). Поля результата — только из контракта.
+- Правила: §1.0–§1.5, §1.2 (`extra`), §1.4 (инварианты), §1.5 (период). Допущения: `ASSUMPTION(OQ-02)` — `is_cumulative`
+ только по тексту; `ASSUMPTION(OQ-45)` — `data_type = NU` выдаётся как есть; `ASSUMPTION(OQ-01)` — «Итого» выдаётся
+ строкой `TOTAL`, решение о хранении — за ядром.
+- Соседи: `../docs` (контракт v1.1, §1), `../fixtures` (синтетика, golden), `arm-core` — копия схемы v1.1 отдельным
+ bounded-PR, логика ядра не меняется (`NoopMapper`).
+
+## Non-goals
+
+- ББ/ОФР из Excel (§12) — подсрез 2b; листы с признаками форм в этом подсрезе пропускаются с предупреждением.
+- Загрузка файлов, `stored_file`/`recognition_job` в ядре, маппинг результата в `document`/`osv_entry`, UI — подсрезы 2c/2d.
+- Проверка `is_cumulative` по данным других ОСВ (OQ-02), резолв контрагентов и счетов — ядро.
+- Отмена разбора извне, JetStream, изменение дедупа `(job_id, attempt)` (OQ-73).
+- Распознавание сканов и PDF.
diff --git a/openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md b/openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md
new file mode 100644
index 0000000..fee2e57
--- /dev/null
+++ b/openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md
@@ -0,0 +1,305 @@
+# Spec Delta: osv-parsing
+
+## Purpose
+
+Разбор оборотно-сальдовых ведомостей из XLS/XLSX/XLSB по `05-domain-rules.md` §1: структура определяется по содержимому,
+результат — `osvDocument` контракта v1.1, достоверность подтверждается контрольными суммами, словари — данные.
+
+## ADDED Requirements
+
+### Requirement: Листы ОСВ находятся по содержимому
+Парсер SHALL считать листом ОСВ любой лист, где ячейка в первых 30 строках листа (`SHEET_MARKER_ROWS`: шапка ведомости
+всегда над таблицей) матчит признак ОСВ из `rules/osv_headers.yaml` (без учёта регистра, «ё/е», переносов строк и
+дефисов). Листы без признаков ОСВ SHALL пропускаться с предупреждением
+`sheet=<имя> skipped: нет признаков ОСВ` в `metrics.warnings`; лист с признаками ББ/ОФР (§12 п. 1) — с предупреждением
+`sheet=<имя> skipped: statement (slice 2b)`. Книга без единого листа ОСВ →
+`status = error`, `error.code = INVALID_DOCUMENT`. Несколько листов ОСВ → несколько `documents` с `locator = sheet:<имя>`.
+Имя листа MUST NOT использоваться для поиска.
+
+#### Scenario: Один лист — одна ОСВ (все реальные фикстуры)
+- **WHEN** разбирается любой файл из `fixtures/osv/**`
+- **THEN** `documents` содержит ровно один `osvDocument` с `locator = "sheet:Лист_1"`, статус `done`
+
+#### Scenario: Два листа ОСВ и посторонний лист
+- **WHEN** разбирается синтетический файл с листами «ОСВ 60», «Справка», «ОСВ 62»
+- **THEN** `documents` содержит два документа в порядке листов, в `warnings` есть запись о пропуске листа «Справка»
+
+#### Scenario: Нет ОСВ
+- **WHEN** разбирается книга с одним листом произвольной таблицы
+- **THEN** `status = error`, `error.code = INVALID_DOCUMENT`, сообщение называет число просмотренных листов
+
+### Requirement: Заголовок таблицы и колонки сумм определяются по тексту
+Парсер SHALL найти блок заголовка таблицы: 1–4 подряд идущих строки, в которых (с учётом объединённых ячеек) встречаются
+группы «сальдо на начало», «обороты за период», «сальдо на конец» по синонимам словаря, и под каждой группой подзаголовки
+«Дебет»/«Кредит» по синонимам. Группы «обороты» и «сальдо на конец» обязательны: строка, где их нет, заголовком не
+считается (R-12). Допустимы: отсутствие группы «начало» (сальдо на начало `null`), одна колонка в группе без
+Дебет/Кредит (свёрнутое сальдо: положительное — дебет, отрицательное — кредит, предупреждение), дополнительные колонки
+(«Количество», «Валюта», «ИНН», «КПП», «Код»). Колонка текста строки — первая непустая слева от сумм по строкам данных;
+колонка ИНН — по заголовку `ИНН` или колонка, где ≥ 80 % непустых значений — ровно 10 или 12 цифр (число из 9/11 цифр
+при этом выборе MUST NOT дополняться нулём: иначе числовая колонка КПП сошла бы за колонку ИНН, R-24). Правая граница таблицы —
+последняя колонка «Кредит»; всё правее MUST NOT попадать в суммы. Колонки внутри таблицы, не сопоставленные с шестью
+суммами и ИНН, SHALL попадать в `rows[].extra` по заголовку. Заголовок не найден или в найденной строке групп нет «оборотов» либо «сальдо
+на конец» → `status = error`, `error.code = INVALID_FORMAT` с названием ненайденной группы, листом и шагом. В `columns` SHALL быть записана диагностика (буква
+колонки) для каждой из шести сумм и ИНН.
+
+#### Scenario: Плавающие колонки двух организаций
+- **WHEN** разбираются `rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx` и `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls`
+- **THEN** `columns` первого содержит `opening_debit = "E"`, `closing_credit = "Q"`, второго — `opening_debit = "G"`,
+ `closing_credit = "S"`, `inn = "E"`; суммы корневой строки совпадают со строкой «Итого»
+
+#### Scenario: Мусор правее таблицы
+- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls` (числа в колонках T/U)
+- **THEN** эти значения не входят ни в одну из шести сумм, инварианты §1.4 выполнены
+
+#### Scenario: ОСВ без сальдо на начало и со свёрнутым сальдо
+- **WHEN** разбирается синтетическая ОСВ с группами только «Обороты» и «Сальдо на конец», где у «конца» одна колонка
+- **THEN** `opening_* = null` у всех строк, отрицательное свёрнутое сальдо попадает в `closing_credit`, в `warnings` есть
+ запись о свёрнутом сальдо, статус `done`
+
+#### Scenario: Неизвестная колонка внутри таблицы
+- **WHEN** в синтетической ОСВ между «ИНН» и суммами есть колонка «КПП»
+- **THEN** у строк контрагентов `extra["КПП"]` содержит значение, суммы не сдвинуты
+
+### Requirement: Шапка документа и схема группировок
+Парсер SHALL извлечь из области выше заголовка таблицы: `organization_name` (первая непустая текстовая строка, trim),
+`account_code_raw` (после «по счету»/«по счёту», как в файле: «60», «58.03»), `period_text` (после «за» до «г.»/конца),
+`data_type` («Выводимые данные: БУ/НУ/сумма»), `currency` («Валюта …: RUB/руб.» → `RUB`), `header_variant` (1 — группировки
+строками первой колонки блока заголовка; 2 — перечисление «субконто: …»). `group_schema` SHALL начинаться с ACCOUNT и далее
+перечислять группировки в порядке вложенности по словарю `rules/osv_groups.yaml`; ячейка `ИНН` в строке группировки →
+`has_inn = true`. `period_quarter`/`is_cumulative` SHALL вычисляться только по тексту по `rules/periods.yaml` (§1.5,
+`ASSUMPTION(OQ-02)`), `period_year` — четыре цифры года. Отсутствие любого поля шапки, кроме обязательных по контракту, —
+`null` и предупреждение; отсутствие `organization_name`/`period_text` — пустая строка и предупреждение (контракт требует
+строку).
+
+#### Scenario: Вариант 1 с тремя строками группировок
+- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls`
+- **THEN** `group_schema = [ACCOUNT, DEPARTMENT, COUNTERPARTY(has_inn=true)]`, `header_variant = 1`,
+ `account_code_raw = "62"`, `period_text = "3 квартал 2024 г."`, `period_quarter = "Q3"`, `is_cumulative = false`,
+ `data_type = "BU"`, `currency = "RUB"`
+
+#### Scenario: ОСВ по субсчёту и годовой период
+- **WHEN** разбираются `rb/2024-Q3/сч 583_3 кв 2024.xlsx` и `rb/2024-FY/ОСВ_66 за 2024.xls`
+- **THEN** у первого `account_code_raw = "58.03"`; у второго `period_quarter = "Q4"`, `is_cumulative = true`
+
+#### Scenario: Вариант 2
+- **WHEN** разбирается синтетическая ОСВ с шапкой «Период: 1 квартал 2026 г.», «Детализация по субсчетам, субконто:
+ Контрагенты, Договоры», заголовком «Субконто»
+- **THEN** `header_variant = 2`, `group_schema = [ACCOUNT, COUNTERPARTY, CONTRACT]`, `period_quarter = "Q1"`
+
+### Requirement: Иерархия строк — каскад стратегий
+Парсер SHALL определять `level` строк данных первой подходящей стратегией из списка: outline level Excel; отступ ячейки
+или ведущие пробелы; шаблон кода счёта и жирность; равенство «сумма детей = родитель». Стратегия подходит, если даёт дерево,
+где у каждой строки есть предок с меньшим уровнем (кроме уровня 0). Строка «Итого» стратегиям MUST NOT передаваться:
+она всегда имеет уровень 0 и не имеет родителя (R-14). Если строк данных (кроме «Итого») меньше двух (пустая ОСВ или одна
+строка) — стратегия `trivial`: дерево из корней (R-13). Выбранная стратегия SHALL быть записана в `metrics.warnings` как
+`sheet=<имя> hierarchy=<имя стратегии>`. `parent_row_number` SHALL указывать на ближайшую
+предыдущую строку с меньшим уровнем.
+
+#### Scenario: Outline level (реальные файлы)
+- **WHEN** разбирается `rb/2024-FY/ОСВ_66 за 2024.xls` (и любой другой реальный файл со строками данных)
+- **THEN** `hierarchy=outline`, строки `66` (0) → `66.01` (1) → контрагент (2); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ»
+ `parent_row_number` — строка `66.03`
+
+#### Scenario: Без outline level
+- **WHEN** разбирается синтетическая ОСВ без группировок Excel, с отступами ячеек (`s02`)
+- **THEN** `hierarchy=indent`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по
+ кодам счетов и жирности (`s03`)
+
+#### Scenario: Без outline и отступов
+- **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`)
+- **THEN** `hierarchy=account_pattern`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт
+
+#### Scenario: Пустая ОСВ — тривиальное дерево
+- **WHEN** в ОСВ нет строк данных кроме «Итого»
+- **THEN** `hierarchy=trivial`, «Итого» — уровень 0 без родителя
+
+### Requirement: Классификация строк
+Каждая строка данных SHALL получить `group_type`: ACCOUNT — текст матчит `ACCOUNT_RE` (регистр учитывается, IGNORECASE
+нет): две-три цифры и до двух сегментов субсчёта через точку, где сегмент — `\d{1,2}` **или 1–3 заглавные буквы**
+кириллицы/латиницы («76.АВ», «76.ВА», «76.ОТ», R-16), далее опционально запятая и название: `^\d{2,3}(\.(\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2}(,\s*.*)?$`
+с флагами `re.ASCII | re.DOTALL` (после запятой допустим любой пробел или его отсутствие, R-18; цифры — только 0–9;
+название после запятой может занимать несколько строк ячейки — M-1); `account_code_raw` — код как в файле, без названия после
+запятой. Не-счёт — группировка из `group_schema` по позиции (число не-счётных предков после последнего ACCOUNT + 1), при
+выходе за схему — OTHER; последняя строка уровня 0 с текстом из словаря «Итого» — TOTAL. Для COUNTERPARTY
+`counterparty_name = row_value`, `counterparty_inn` — **только** из колонки ИНН: цифры текстовой ячейки колонки («ИНН
+352819549855» → «352819549855», R-15), результат только при 10 или 12 цифрах, иначе `null`; число из 9/11 цифр
+(ИНН регионов 01–09 с потерянным ведущим нулём) при извлечении дополняется нулём слева (R-24); ИНН внутри текста строки
+(«Полиграф, 3528315710») MUST NOT извлекаться (R-17). Строки «Вал.», «Кол.», трёхбуквенного кода валюты SHALL получать
+`group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST NOT занимать позицию в `group_schema` и MUST NOT наследовать
+`counterparty_name`/`counterparty_inn` (`account_code_raw` наследуется от ветки, R-19); их суммы остаются в шести полях.
+Остальные строки получают `data_type` шапки. Все строки, включая TOTAL, SHALL присутствовать в `rows`
+(`ASSUMPTION(OQ-01)`: хранить ли «Итого», решает ядро).
+
+#### Scenario: Подразделение, контрагент с ИНН, физлицо
+- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls`
+- **THEN** «БП Аренда» → DEPARTMENT; «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» → COUNTERPARTY с `counterparty_inn` из 10 цифр;
+ «Ведомость в счет з/платы» → COUNTERPARTY с `counterparty_inn = null`
+
+#### Scenario: Название субсчёта в строке счёта
+- **WHEN** в синтетической ОСВ строка «60.01, Расчеты с поставщиками»
+- **THEN** `group_type = ACCOUNT`, `account_code_raw = "60.01"`, `row_value` — исходный текст
+
+#### Scenario: Буквенный субсчёт
+- **WHEN** в ОСВ 76 строка «76.АВ» (реальные файлы РБ и Счастливочной) либо «60.АВ, …» (синтетика)
+- **THEN** `group_type = ACCOUNT`, строка наследуется детьми как счёт ветки; «76.ав» (строчные) счётом не считается
+
+#### Scenario: Строки Вал./Кол.
+- **WHEN** в синтетической ОСВ под контрагентом строки «USD» и «Кол.»
+- **THEN** `group_type = OTHER`, `data_type = CURRENCY` и `QUANTITY`, `counterparty_name`/`counterparty_inn` равны `null`,
+ `account_code_raw` совпадает с родителем, строки исключены из проверки «сумма детей = родитель», статус `done`
+
+### Requirement: Числа — Decimal, пусто — null
+Суммы SHALL приводиться к `Decimal` с двумя знаками: числовые ячейки — через строковое представление; текстовые —
+после удаления пробелов (включая неразрывные и узкие), замены запятой на точку, `(x)` → `-x`; «-», «—», «–», пустая ячейка →
+`null` (не 0). Нечисловой текст в колонке сумм → `null` и предупреждение с `row_number`. `float` в результате MUST NOT
+использоваться; в JSON суммы — строки `^-?\d+(\.\d{1,2})?$`.
+
+#### Scenario: Текстовые числа
+- **WHEN** в синтетической ОСВ суммы записаны как «1 234 567,89», «(1 234,00)», «—»
+- **THEN** получены `1234567.89`, `-1234.00`, `null`
+
+### Requirement: Контрольные суммы определяют статус
+Для каждого документа парсер SHALL проверить: по каждой строке `opening_debit − opening_credit + turnover_debit −
+turnover_credit = closing_debit − closing_credit` (±0.01, `null` = 0); по каждому родителю суммы = Σ детей с тем же
+`data_type` (±0.01), причём **обороты** сверяются по колонкам, а **сальдо** (начало, конец) — по колонкам либо, если у
+родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто «дебет − кредит» (R-21: 1С показывает счёт нетто,
+а субсчета и контрагентов — развёрнуто; остаточные риски перечислены ниже); строки CURRENCY/QUANTITY в суммирование не входят и сами
+родителями не проверяются; строка TOTAL = корневая строка ACCOUNT (первая строка уровня 0). Если корня нет (пустая ОСВ,
+R-13), «Итого» SHALL быть пустой или нулевой. Если строка «Итого» не найдена, `total_row_matches_root = false` и в
+`warnings` — «строка «Итого» не найдена» (R-20). Результат — `checks{total_row_matches_root,
+balance_identity_violations, parent_sum_violations}`. Любое нарушение → `status = needs_check` всего результата и запись
+причины в `metrics.warnings`; парсер MUST NOT подгонять структуру под сходимость.
+
+Остаточные риски (известные слепые зоны, не ловятся проверками §1.4):
+- RR-1 — у свёрнутого родителя сверяется только нетто сальдо: распределение сальдо детей между дебетом и кредитом
+ (перестановка сторон при совпавшем нетто) не проверяется.
+- RR-2 — «свёрнутость» определяется по самому родителю: развёрнутое сальдо родителя, у которого в файле потеряна одна
+ сторона, неотличимо от нетто-показа 1С и проходит, если нетто совпало.
+- RR-3 — нетто допускается на любом уровне дерева (счёт, субсчёт, контрагент, договор), а не только на уровне «счёт →
+ субсчета», где его показывает 1С: ошибка распределения сальдо между договорами под свёрнутым контрагентом не ловится.
+- RR-4 — тождество строки сверяет только нетто строки, «Итого = корень» — только корень: ошибка, сохраняющая нетто
+ строки, ловится лишь сверкой с родителем по колонкам, а под свёрнутым родителем — ничем (см. слепую зону ниже).
+- Слепая зона: обе стороны сальдо (на начало или на конец) развёрнутого ребёнка увеличены на одно и то же X, а его
+ непосредственный родитель свёрнут — не ловится ничем: достаточно свёрнутого прямого родителя (сверка с ним — по нетто,
+ а нетто ребёнка не изменилось; значения родителя и выше не затронуты; тождество строки и «Итого = корень» сохраняются).
+- Родитель с сальдо (0, 0) при детях (X Дт, X Кт) принимается: нулевое сальдо — свёрнутое, нетто детей X − X = 0
+ (закреплено тестом как осознанное поведение R-21).
+- Вне R-21: текст вида «12.ООО» (2–3 цифры, точка, до трёх заглавных букв) принимается за счёт (R-16) и меняет
+ `group_type` строк ветки; суммы и проверки это не затрагивает.
+
+#### Scenario: Все реальные файлы сходятся
+- **WHEN** разбирается каждый файл `fixtures/osv/**`
+- **THEN** `status = done`, `checks.total_row_matches_root = true`, оба списка нарушений пусты
+
+#### Scenario: Испорченная сумма
+- **WHEN** в синтетической ОСВ у одного контрагента `closing_debit` увеличен на 1
+- **THEN** `status = needs_check`, `balance_identity_violations` содержит его `row_number`, `parent_sum_violations` —
+ `row_number` родителя, в `warnings` — текст с номерами строк
+
+#### Scenario: Сальдо родителя свёрнуто нетто
+- **WHEN** у родителя сальдо показано одной стороной (нетто), а у детей — обеими сторонами, и нетто детей равно нетто родителя
+- **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто
+ или расхождение оборотов — нарушение
+
+#### Scenario: «Итого» не найдена
+- **WHEN** в таблице нет строки с текстом из словаря «Итого»
+- **THEN** `total_row_matches_root = false`, `status = needs_check`, в `warnings` — «строка «Итого» не найдена»
+
+#### Scenario: Пустая ОСВ
+- **WHEN** разбираются реальные пустые ОСВ (в фикстурах две: `schastl/2025-Q2/…58 за 2 квартал 2025 г..xlsx` и `…66 за 2
+ квартал 2025 г..xlsx`: заголовок и сразу «Итого») либо любой файл, где кроме «Итого» строк данных нет
+- **THEN** `status = done`, `rows` содержит только строку TOTAL, `metrics.rows = 0` (строки данных без TOTAL),
+ `hierarchy=trivial`, `checks.total_row_matches_root = true` (корня нет, суммы TOTAL пусты), предупреждение
+ «нет строк данных»
+
+### Requirement: Форматы, лимиты, время
+Парсер SHALL читать XLSX (openpyxl, полное чтение), XLS (xlrd, `formatting_info`), XLSB (pyxlsb; outline level недоступен —
+каскад начинается с отступа). Несоответствие `file_type` сигнатуре файла → попытка по сигнатуре и предупреждение.
+Файл больше `PARSE_MAX_BYTES` (по умолчанию 50 МиБ) → `error UNSUPPORTED_FORMAT` с текстом про лимит. До вызова
+библиотеки чтения SHALL действовать лимиты чтения (R-23) → `error UNSUPPORTED_FORMAT` с текстом про лимит: сумма
+объявленных несжатых размеров записей zip (xlsx/xlsb) не больше `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` (10 ×);
+суммарная площадь объединённых ячеек книги не больше `MERGED_AREA_LIMIT` (200 000 ячеек). Для xlsx — до openpyxl
+потоковым разбором `xml.parsers.expat` всех частей архива (R-25, I-1б: каждая некаталожная запись в любом каталоге,
+без отбора по имени или началу содержимого; ошибка разбора части не `*.xml` лишь заканчивает её скан; элемент с локальным именем `mergeCell` в любом пространстве имён; только атрибут `ref` без
+префикса; ссылки на символы раскрываются; площадь — по строке `ref`, без перебора ячеек; отказ сразу при превышении);
+для любого формата — после чтения, по диапазонам, которые индекс листа раскладывает по ячейкам (площадь не больше
+`grid.MAX_INDEXED_AREA`; merged на весь столбец не считается); число остальных (крупных) диапазонов листа — не больше
+`LARGE_MERGED_COUNT_LIMIT` (1000). Книга не открывается (в том числе битый XML части
+`*.xml`, `ref` объединения, который openpyxl не разберёт) → `UNREADABLE` (лог WARNING);
+превышение `PARSE_TIMEOUT_SECONDS` (по умолчанию 120, проверка между листами, в циклах по всему листу — поиск заголовка
+таблицы, дополнительные колонки, строки, стратегии иерархии; чтение книги библиотекой таймаут не прерывает) → `TIMEOUT`;
+непредвиденное исключение → `RECOGNITION_FAILED` с одной строкой причины, стек — в лог уровня ERROR. Не ограничены (остаточный
+риск, решение платформы — лимит памяти контейнера, `defusedxml`): объём самих ячеек, `sharedStrings` и стилей в
+пределах лимита распакованного zip, XML-бомбы entity expansion (кроме защиты самого expat ≥ 2.4). Любой файл
+`fixtures/osv/**` SHALL разбираться быстрее 5 с.
+
+#### Scenario: XLSB
+- **WHEN** разбирается синтетический xlsb той же ОСВ, что `s02` (`s13`, собран тестовым писателем BIFF12, а не Excel — O-7)
+- **THEN** результат совпадает с xlsx-оригиналом во всём, кроме `locator` и предупреждения «xlsb: outline level
+ недоступен»; `hierarchy=indent`
+
+#### Scenario: Расширение не совпадает с содержимым
+- **WHEN** запрос говорит `XLSX`, а байты — BIFF8
+- **THEN** файл прочитан как xls, в `warnings` есть запись о несоответствии, статус по содержимому
+
+#### Scenario: Превышен лимит
+- **WHEN** передан файл в 51 МиБ
+- **THEN** `error UNSUPPORTED_FORMAT`, книга не открывается
+
+#### Scenario: Бомба объединённых ячеек
+- **WHEN** передан xlsx в несколько килобайт с `` (5,2 млн ячеек)
+- **THEN** `error UNSUPPORTED_FORMAT` («лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается
+
+#### Scenario: Обходы скана объединённых ячеек
+- **WHEN** тот же диапазон спрятан: лист вне `xl/worksheets/` или с расширением не `.xml` (цель связи в
+ `workbook.xml.rels`), `ref` записан ссылками на символы (`A1:Z200000`, `A1:…`), перед `ref` стоит атрибут
+ с префиксом (`a:ref="A1"`), в значении другого атрибута есть `>`, элемент записан с префиксом (`x:mergeCell`)
+- **THEN** `error UNSUPPORTED_FORMAT` («лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается; тот же вариант с
+ малым диапазоном читается, и объединение видно в листе
+
+#### Scenario: Объединение на весь столбец в xls
+- **WHEN** в книге xls есть законный merged-диапазон на весь столбец (больше `grid.MAX_INDEXED_AREA`)
+- **THEN** книга читается: после-чтения проверка его не считает (индекс листа его по ячейкам не раскладывает)
+
+#### Scenario: Распакованный zip больше лимита
+- **WHEN** zip книги распаковывается больше чем в 10 × `PARSE_MAX_BYTES`
+- **THEN** `error UNSUPPORTED_FORMAT` («распакованный размер … больше лимита»), книга не открывается
+
+### Requirement: Словари — данные, не код
+Признаки листов, синонимы групп колонок и подзаголовков, колонки ИНН, строки «Итого», маркеров шапки (`rules/osv_headers.yaml`:
+`sheet_markers`, `statement_markers`, `groups`, `debit`, `credit`, `service_columns`, `total_row`, `header_markers`),
+группировок (`rules/osv_groups.yaml`: `mapping`, `default`), периодов (`rules/periods.yaml`: `rules`, `year`, `end`),
+типов данных и кодов валют (`rules/data_types.yaml`: `header`, `row`, `currency_codes`, `currency_aliases`)
+SHALL загружаться из YAML и валидироваться pydantic-моделью при старте (ошибка словаря — падение старта с указанием файла и
+записи). В `src/parser/` (кроме `grid.py` и `readers/`) MUST NOT быть строковых литералов букв колонок, адресов ячеек,
+имён листов и сравнений с номерами строк — проверяется тестом по AST. Каждая запись распознавания словаря (паттерн) SHALL
+срабатывать хотя бы на одной фикстуре или синтетике (тест покрытия словарей, R-22); перечень кодов валют
+`currency_codes` (ISO 4217) — список значений, а не синонимы, и в покрытие не входит.
+
+#### Scenario: Новый синоним без релиза кода
+- **WHEN** в `osv_headers.yaml` добавлен синоним «Нач. остаток» и дана синтетическая ОСВ с таким заголовком
+- **THEN** разбор проходит без изменений в `src/`; без этого синонима заголовок «Нач. остаток» не распознаётся
+ (колонки начала уходят в `extra`, результат `needs_check`)
+
+#### Scenario: Литерал координаты
+- **WHEN** в `detect.py` появляется строка `"E9"`
+- **THEN** тест-линтер падает с указанием файла и строки
+
+#### Scenario: Мёртвая запись словаря
+- **WHEN** в словарь добавлена запись, не срабатывающая ни на одном реальном или синтетическом файле
+- **THEN** тест покрытия падает с указанием файла, ключа и паттерна
+
+### Requirement: Золотые снимки и property-тесты
+Для каждого файла `fixtures/osv/**` SHALL существовать снимок `fixtures/golden/expected_osv_parse/<зеркальный путь>.json`
+(= файл результата без `job_id`, `file_id`, `service.version`, `metrics.duration_ms`); тест сравнивает разбор со снимком,
+обновление — только явной командой (`make golden-update`) с просмотром diff. Property-тест по всем файлам SHALL проверять
+инварианты независимо от снимков (статус ≠ `error`, контрольные суммы, корень = счёт шапки, период распознан). Без
+каталога fixtures оба теста пропускаются с причиной (`ARM_KA_FIXTURES`).
+
+#### Scenario: Регрессия
+- **WHEN** правка парсера меняет `level` одной строки в любом реальном файле
+- **THEN** golden-тест падает с diff по этому файлу
+
+#### Scenario: Новый файл без снимка
+- **WHEN** в `fixtures/osv/` добавлен файл без снимка
+- **THEN** property-тест его разбирает, golden-тест сообщает «нет снимка: выполните make golden-update»
diff --git a/openspec/changes/slice-2a-osv-parser/specs/tabular-recognition-service/spec.md b/openspec/changes/slice-2a-osv-parser/specs/tabular-recognition-service/spec.md
new file mode 100644
index 0000000..cb27daf
--- /dev/null
+++ b/openspec/changes/slice-2a-osv-parser/specs/tabular-recognition-service/spec.md
@@ -0,0 +1,43 @@
+# Spec Delta: tabular-recognition-service
+
+## MODIFIED Requirements
+
+### Requirement: Результат по контракту
+После подтверждения сервис SHALL прочитать объект `s3.key` из бакета `s3.bucket`, вызвать парсер, записать файл результата
+(по `recognition-result.schema.json`) в `results/{file_id}/{job_id}/result.json` и опубликовать конверт результата (по
+`recognition-result-message.schema.json`) с заголовком `Nats-Msg-Id = result::`. При неудаче публикации
+сервис SHALL повторить её до трёх раз с паузой, затем залогировать ошибку.
+
+#### Scenario: Запрос с заглушкой парсера (Срез 0)
+- **WHEN** воркеру явно передана заглушка `NullParser` (только тесты воркера; реализация по умолчанию — реальный парсер),
+ запрос подтверждён и объект существует в `uploads/`
+- **THEN** в `recognition.result` появляется конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `job_id`/`file_id`
+ из запроса, `metrics.duration_ms ≥ 0`; файл результата может отсутствовать
+
+#### Scenario: Запрос с реальным парсером
+- **WHEN** запрос подтверждён и объект в `uploads/` существует, но не является книгой Excel (сигнатура не XLS/XLSX/XLSB)
+- **THEN** в `recognition.result` появляется конверт `status = error`, `error.code = UNSUPPORTED_FORMAT` (повреждённая
+ книга — `UNREADABLE`), `job_id`/`file_id` из запроса, `metrics.duration_ms ≥ 0`; файл результата может отсутствовать;
+ корректная ОСВ в том же потоке даёт `done` (см. «Парсер — заменяемый интерфейс»)
+
+#### Scenario: Повтор публикации
+- **WHEN** первая публикация конверта результата завершается ошибкой соединения, вторая — успешно
+- **THEN** конверт опубликован один раз с тем же `Nats-Msg-Id`, обработка не повторяется
+
+### Requirement: Парсер — заменяемый интерфейс
+Разбор файла SHALL выполняться через единый интерфейс «байты + тип файла → документы/метрики/ошибка», не знающий о
+NATS и S3; воркер MUST NOT зависеть от конкретной реализации. Реализация по умолчанию (`get_parser()`) SHALL разбирать
+ОСВ по спеке `osv-parsing` и писать файл результата с `schema_version = "1.1"`; листы с признаками ББ/ОФР SHALL
+пропускаться с предупреждением до подсреза 2b. Заглушка `NullParser` (`error NOT_IMPLEMENTED`) SHALL использоваться только
+в тестах воркера и MUST NOT быть реализацией по умолчанию. Реализация по умолчанию SHALL создаваться один раз при старте
+приложения (словари читаются и проверяются там же). Каталог `api/rules/` SHALL содержать словари ОСВ (`osv_headers`,
+`osv_groups`, `periods`, `data_types`) и README.
+
+#### Scenario: Подмена реализации в тесте
+- **WHEN** в тесте воркеру передан парсер, возвращающий один `osvDocument`
+- **THEN** файл результата с этим документом записан в S3, конверт имеет `status = done` и `result_s3` на этот файл
+
+#### Scenario: Реальная ОСВ через воркер
+- **WHEN** в интеграционном тесте в S3 лежит `rb/2024-FY/ОСВ_66 за 2024.xls` и опубликован запрос `file_type = XLS`
+- **THEN** после ack публикуется конверт `done` с `result_schema_version = "1.1"`, файл результата проходит схему v1.1 и
+ содержит один `osvDocument` с `account_code_raw = "66"`
diff --git a/openspec/changes/slice-2a-osv-parser/tasks.md b/openspec/changes/slice-2a-osv-parser/tasks.md
new file mode 100644
index 0000000..fb83b1f
--- /dev/null
+++ b/openspec/changes/slice-2a-osv-parser/tasks.md
@@ -0,0 +1,78 @@
+# Tasks: Срез 2a — парсер ОСВ (tabular-parser)
+
+Каждая задача ≤ половины дня, начинается с тестов, заканчивается зелёным `make check`. Пути — от корня репозитория.
+Фикстуры — `../fixtures` (worktree `.wt/slice-2/fixtures`, ветка `feat/4-slice-2a-osv-parser`); контракт v1.1 —
+`../docs/contracts` (ветка `feat/9-slice-2a-osv-parser`).
+
+## 1. Основание
+
+- [x] 1.1 Зависимости и фикстуры: `openpyxl==3.1.5`, `xlrd==2.0.2`, `pyxlsb==1.0.10` в `api/requirements.txt`; `make venv`;
+ `tests/fixtures_helpers.py` (`ARM_KA_FIXTURES`, как в arm-core) и `conftest.py` с фикстурами `osv_file` (все файлы
+ `../fixtures/osv/**` кроме `synthetic`) и `synthetic_file` (`../fixtures/osv/synthetic/*`), параметризация по
+ относительному пути; маркер `fixtures` в деле; `test_layout`
+ не ломается. Проверка: `pytest -m fixtures --collect-only` показывает 66 параметров.
+- [x] 1.2 Контракт v1.1: скопировать `../docs/contracts/recognition-result.schema.json` v1.1 в `api/contracts/`, `OsvRow.extra:
+ dict[str, str | None] | None`, `CONTRACT_VERSION = "1.1"`; примеры docs проходят; тест идентичности зелёный; воркер пишет
+ `result_schema_version = "1.1"` (`test_worker` обновлён). Проверка: `make check`.
+- [x] 1.3 `grid.py` и читатели: `Cell`, `Sheet`, `MergedRange`, `column_letter`; `readers/xlsx.py` (openpyxl, полное чтение,
+ `outline_level`, `alignment.indent`, bold), `readers/xls.py` (xlrd `formatting_info`, `rowinfo_map`, XF bold/indent),
+ `readers/xlsb.py` (pyxlsb, без outline), `readers/__init__.py:read_book(data, file_type) -> (sheets, warnings)` с
+ определением по сигнатуре и предупреждением при несоответствии; ошибки → `UnreadableError`/`UnsupportedFormatError`.
+ Тесты: по читателю на собранной книге, реальные xls/xlsx читаются с outline и merged, синтетический xlsb (писателей
+ xls/xlsb для проверки «три читателя дают одинаковые ячейки» нет — решение R-11, design №21);
+ `fixtures`-тест: все 66 файлов читаются < 5 с каждый.
+- [x] 1.4 Словари: `api/rules/{osv_headers,osv_groups,periods,data_types}.yaml` (содержимое по §1.1/§1.5 и синонимам из §1.0),
+ `src/parser/rules.py:load_rules(path) -> Rules` (pydantic, компиляция регулярок с нормализацией ё/е и пробелов),
+ тест на падение при битом словаре. Покрытие словарей — задача 4.3.
+
+## 2. Обнаружение структуры
+
+- [x] 2.1 `detect.py`: `classify_sheet` (вид листа: ОСВ / форма ББ-ОФР / прочее), `find_table_header` (группы по
+ merged-тексту в 1–4 строках, Дебет/Кредит под группой, обязательны «обороты» и «конец» — R-12, свёрнутое сальдо,
+ отсутствие «начала», `right_edge`, `text_col`, `inn_col` по заголовку и по доле 10/12 цифр,
+ `extra`-колонки). Unit-тесты на сетках: заголовок 1/2/3/4 строки, merged, без «начала», свёрнутое сальдо, КПП между
+ ИНН и суммами, мусор правее. `fixtures`-тест: `columns` для РБ `E/H/J/L/O/Q`, для Счастливочной xls `G/J/K/N/Q/S` + `inn=E`.
+- [x] 2.2 `header.py`: шапка документа (организация, счёт, период, data_type, валюта), `group_schema` вариант 1 и 2, `has_inn`,
+ период → `period_year`, `period_quarter`, `is_cumulative` по `periods.yaml` (`ASSUMPTION(OQ-02)`). Unit-тесты: обе шапки,
+ отсутствие полей → `null` + warning, субсчёт 58.03, все строки таблицы §1.5. `fixtures`-тест: у всех 66 файлов
+ `account_code_raw` совпадает с кодом в имени каталога/файла по регулярке и период распознан.
+- [x] 2.3 `hierarchy.py`: стратегии `outline`, `indent`, `account_pattern`, `sums`, каскад с проверкой непротиворечивости,
+ `parent_row_number`. Unit-тесты на сетках для каждой стратегии и для отказа стратегии (одинаковые уровни); `fixtures`-тест:
+ `hierarchy=outline` у всех файлов со строками данных, `trivial` у пустых ОСВ (R-13: меньше двух строк данных; в
+ фикстурах таких две — schastl 58 и 66 за 2 кв. 2025).
+- [x] 2.4 `classify.py` + `numbers.py`: классификация (ACCOUNT по регулярке, позиция в `group_schema`, OTHER при выходе,
+ TOTAL, COUNTERPARTY с ИНН, Вал./Кол./код валюты → `data_type`), числа (Decimal, текстовые форматы, скобки, прочерки,
+ экспонента), `extra`. Unit-тесты по §1.3 и §1.2; `fixtures`-тест: в `schastl …62 за 3 кв 2024` «БП Аренда» → DEPARTMENT,
+ «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» с ИНН, физлицо без ИНН.
+
+## 3. Сборка и статусы
+
+- [x] 3.1 `checks.py` + `osv.py`: инварианты §1.4 (±0.01, `null` = 0, CURRENCY/QUANTITY исключены), `checks`, статусы D-6,
+ `Deadline` и `PARSE_MAX_BYTES`/`PARSE_TIMEOUT_SECONDS` в `Settings` (+ `.env.example`), `OsvParser.parse(data, file_type)
+ -> ParseOutcome`, `get_parser()` возвращает его, `NullParser` остаётся только для тестов воркера. Unit-тесты: карта
+ статусов (нет листов, нет заголовка, лимит, таймаут через монкипатч часов, исключение → RECOGNITION_FAILED), испорченная
+ сумма → `needs_check` с номерами строк. `fixtures`-тест (property): все 66 — `done`, инварианты, корень = счёт шапки.
+- [x] 3.2 Интеграция с воркером: `test_worker` на реальном `OsvParser` с байтами `ОСВ_66 за 2024.xls` (unit, без NATS) и
+ интеграционный roundtrip (NATS + MinIO): конверт `done`, `result_schema_version = "1.1"`, файл результата проходит схему.
+ README: раздел «Запуск на файле» (`python -m src.parser.cli <файл>` — печать результата JSON; добавить CLI).
+
+## 4. Синтетика, golden, линтер
+
+- [x] 4.1 Генератор синтетики `tests/synthetic/generate.py` (детерминированный openpyxl) → `../fixtures/osv/synthetic/`: вариант 2
+ шапки; без outline (отступы); без outline и отступов; Вал./Кол.; текстовые числа и скобки; два листа ОСВ + посторонний;
+ без «начала» + свёрнутое сальдо; КПП-колонка; название субсчёта в строке; испорченная сумма; плюс xlsb (s13), собранный тестовым писателем BIFF12 `tests/synthetic/xlsb_writer.py` (LibreOffice нет, R-9): не из
+ Excel, совместимость с настоящим xlsb не доказана (O-7), пометка и инструкция замены — в README fixtures; s14–s16 —
+ записи словарей, которых нет в реальных файлах (R-22). Тесты сценариев спеки на этих файлах.
+- [x] 4.2 Golden: `tests/golden_helpers.py` (нормализация результата без volatile-полей), тест сравнения для всех
+ `fixtures/osv/**` включая синтетику, `make golden-update` (перезапись + diff), первичная запись снимков в
+ `../fixtures/golden/expected_osv_parse/` после ручной сверки «Итого» трёх файлов (РБ 60 1 кв 2025, Счастливочная 62 3 кв
+ 2024 xls, 58.03) — сверка описывается в отчёте задачи.
+- [x] 4.3 Линтер координат `tests/unit/parser/test_no_coordinates.py` (AST по `src/parser/**` кроме `grid.py`, `readers/`) и тест
+ покрытия словарей (каждая запись сработала хотя бы раз на фикстурах+синтетике; список несработавших — в сообщении).
+
+## 5. Завершение
+
+- [x] 5.1 Документы: README (архитектура парсера, статусы и коды, словари, golden-процесс, лимиты), CLAUDE.md (модули, правила
+ расширения), `rules/README.md`; `make check-full` (lint, unit, fixtures, integration, docker) зелёный, вывод — в PR.
+ Проверка: сценарии всех требований `osv-parsing` имеют тест (таблица в отчёте задачи).
+- [ ] 5.2 После merge: `openspec archive slice-2a-osv-parser`.