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`.