docs: README/CLAUDE.md Среза 2a, tasks.md, спека-дельты

- README: парсер по модулям, статусы и коды, словари и добавление синонима, лимиты, golden-процесс, настройки PARSE_*
- CLAUDE.md: раздел «Добавлено в Срезе 2a» (модули, правила расширения, линтер, R-21); api/rules/README.md: ключи словарей
- openspec: tasks.md (5.1), design.md (D-1…D-7, O-7/O-8), спека-дельты osv-parsing (R-12…R-22) и tabular-recognition-service («Результат по контракту»)
- tests: check_snapshot в golden_helpers + тесты «нет снимка»/«регрессия»; locator в property-тесте; пример needs_check синхронизирован с docs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 15:53:11 +04:00
parent bc143ef140
commit 7738221b5a
14 changed files with 431 additions and 134 deletions

View File

@ -38,14 +38,54 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
`raisa_fastapi_protected_api`; шаблонный `README.md` заменён описанием сервиса. `raisa_fastapi_protected_api`; шаблонный `README.md` заменён описанием сервиса.
**Добавлено** (в скобках — задача `openspec/changes/slice-0-skeleton/tasks.md`): **Добавлено** (в скобках — задача `openspec/changes/slice-0-skeleton/tasks.md`):
- `api/src/parser/` — интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 и форм §12 — чистые функции над - `api/src/parser/` — интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 — Срез 2a (см. ниже), форм §12 —
книгой Excel, Срезы 2+; Срез 2b;
- `api/src/contract/` — pydantic-модели контракта v1.1 (запрос, результат, конверт); `api/contracts/` — копии схем (идентичность с - `api/src/contract/` — pydantic-модели контракта v1.1 (запрос, результат, конверт); `api/contracts/` — копии схем (идентичность с
`../docs/contracts` проверяется в `make check`) — 3.1; `../docs/contracts` проверяется в `make check`) — 3.1;
- `api/rules/` — README о назначении; сами словари `*.yaml` (в документах `rules/*.yaml` относительно `api/`) - `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` на - `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; `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. - `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`.
**Контракт 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. Проверки: `make check` (`pytest`, `ruff check . && ruff format --check .`, `mypy src`), `make check-full` перед PR.

134
README.md
View File

@ -9,10 +9,14 @@ Stateless-сервис распознавания табличных файло
Обмен с `arm-core` идёт по контракту **v1.1**: схемы — [`../docs/contracts/`](../docs/contracts/), копия — в Обмен с `arm-core` идёт по контракту **v1.1**: схемы — [`../docs/contracts/`](../docs/contracts/), копия — в
`api/contracts/` (идентичность с источником проверяется в `make check`). `api/contracts/` (идентичность с источником проверяется в `make check`).
**Срез 0 — скелет.** Транспорт работает полностью (подписка, ack, чтение S3, публикация результата, ошибки транспорта), **Срез 0 — скелет.** Транспорт работает полностью (подписка, ack, чтение S3, публикация результата, ошибки транспорта);
а сам разбор заменён заглушкой: на любой корректный запрос сервис отвечает конвертом `status = error` с разбор подключается через интерфейс `Parser` без изменений воркера.
`error.code = NOT_IMPLEMENTED`. Настоящий парсер (ОСВ — Срез 2, формы ББ/ОФР — позже) подключается через интерфейс
`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 АРМ КА. Общий контекст, модель, правила предметной области и контракты — в соседних Репозиторий — часть workspace АРМ КА. Общий контекст, модель, правила предметной области и контракты — в соседних
репозиториях: репозиториях:
@ -40,8 +44,9 @@ Stateless-сервис распознавания табличных файло
при неудаче повторяется до `PUBLISH_RETRIES` раз (после первой попытки, с паузой 2 с). при неудаче повторяется до `PUBLISH_RETRIES` раз (после первой попытки, с паузой 2 с).
Ошибки транспорта процесс не роняют, а превращаются в конверт `status = error`: нечитаемый или несовместимый запрос — Ошибки транспорта процесс не роняют, а превращаются в конверт `status = error`: нечитаемый или несовместимый запрос —
`SCHEMA_MISMATCH` (ack при этом не публикуется), объект S3 не читается — `FILE_READ_ERROR`, исключение парсера — `SCHEMA_MISMATCH` (ack при этом не публикуется), объект S3 не читается — `FILE_READ_ERROR`, исключение, вышедшее из
`UNKNOWN_ERROR`, заглушка парсера в Срезе 0 — `NOT_IMPLEMENTED`. парсера, — `UNKNOWN_ERROR`. Ожидаемые сбои разбора (не тот формат, нет листа ОСВ, лимит размера или времени) парсер
возвращает сам исходом `status = error` с кодом контракта — см. «Статусы и коды» ниже.
## Состав репозитория ## Состав репозитория
@ -51,16 +56,18 @@ api/ бэкенд (все команды разработки
src/app.py FastAPI: lifespan (MinIO, NATS, воркер), GET /healthcheck, middleware, обработчики ошибок src/app.py FastAPI: lifespan (MinIO, NATS, воркер), GET /healthcheck, middleware, обработчики ошибок
src/core/ config.py (настройки), clients.py (S3), nats.py (соединение NATS), worker.py (воркер src/core/ config.py (настройки), clients.py (S3), nats.py (соединение NATS), worker.py (воркер
распознавания), logger.py, exception_handlers.py распознавания), logger.py, exception_handlers.py
src/parser/ интерфейс Parser и заглушка NullParser (разбор — Срез 2) src/parser/ парсер ОСВ (Срез 2a): интерфейс Parser, OsvParser, читатели, детекторы, иерархия, классификация,
контрольные суммы, CLI; заглушка NullParser — для тестов воркера (модули — ниже)
src/contract/ pydantic-модели контракта v1.1 src/contract/ pydantic-модели контракта v1.1
contracts/ копии JSON-схем контракта и примеры contracts/ копии JSON-схем контракта и примеры
rules/ словари синонимов *.yaml для парсера (пока только README; словари — Срез 2) rules/ словари синонимов *.yaml (osv_headers, osv_groups, periods, data_types) и README
tests/ pytest: unit и integration (маркер `integration`) scripts/ golden_update.py — обновление golden-снимков
tests/ pytest: unit, integration (маркер `integration`), fixtures (маркер `fixtures`), synthetic
setup/ pip.conf и setup.sh (создание venv без uv) setup/ pip.conf и setup.sh (создание venv без uv)
raisa_config.yml конфигурация платформы РАИСА raisa_config.yml конфигурация платформы РАИСА
Makefile команды разработки Makefile команды разработки
Dockerfile образ сервиса, python:3.11-slim Dockerfile образ сервиса, python:3.11-slim
openspec/ требования и дельта текущего среза (openspec/changes/slice-0-skeleton/) openspec/ требования и дельта текущего среза (openspec/changes/slice-2a-osv-parser/)
``` ```
## Предварительные требования ## Предварительные требования
@ -81,16 +88,106 @@ openspec/ требования и дельта текущего ср
|---|---| |---|---|
| `make venv` | создаёт `api/.venv` (Python 3.11) и ставит `requirements.txt` и `requirements-dev.txt` через `uv` | | `make venv` | создаёт `api/.venv` (Python 3.11) и ставит `requirements.txt` и `requirements-dev.txt` через `uv` |
| `make lint` | `ruff check .`, `ruff format --check .`, `mypy src` | | `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 test-integration` | `pytest -m integration` — против NATS и MinIO из compose |
| `make test-fixtures` | `pytest -m fixtures` — тесты на реальных ОСВ и синтетике из `../fixtures`: golden-снимки, покрытие словарей, property-тест | | `make test-fixtures` | `pytest -m fixtures` — тесты на реальных ОСВ и синтетике из `../fixtures`: golden-снимки, покрытие словарей, property-тест |
| `make golden-update` | перезаписывает golden-снимки в `../fixtures/golden/expected_osv_parse/` и печатает unified diff изменившихся | | `make golden-update` | перезаписывает golden-снимки в `../fixtures/golden/expected_osv_parse/` и печатает unified diff изменившихся |
| `make golden-check` | то же без записи; код возврата 1, если снимки расходятся с разбором | | `make golden-check` | то же без записи; код возврата 1, если снимки расходятся с разбором |
| `make check` | `make lint` + `make test` (включая идентичность копии контракта с `../docs/contracts`) | | `make check` | `make lint` + `make test` (включая идентичность копии контракта с `../docs/contracts`); с каталогом fixtures ≈ 95 с — из-за тестов на 82 файлах |
| `make check-full` | `make check` + `make test-integration` + `docker build`; запускать перед PR | | `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 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` — предупреждение |
| `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) |
| `error` / `UNSUPPORTED_FORMAT` | сигнатура не XLS/XLSX/XLSB либо размер больше `PARSE_MAX_BYTES` |
| `error` / `TIMEOUT` | превышен `PARSE_TIMEOUT_SECONDS` |
| `error` / `RECOGNITION_FAILED` | непредвиденное исключение, одной строкой `Тип: сообщение` |
В `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`; зависание внутри библиотеки чтения он не прерывает — там работает лимит размера |
Любой реальный файл разбирается быстрее 5 с (измерено: максимум ≈ 0,6 с).
## Тесты на реальных данных и golden-снимки ## Тесты на реальных данных и golden-снимки
@ -110,6 +207,12 @@ git -C ../fixtures add golden && git -C ../fixtures commit # снимки —
``` ```
Править снимки вручную нельзя; новый файл в `osv/` без снимка — падение «нет снимка: выполните make golden-update». Править снимки вручную нельзя; новый файл в `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` Дополнительно: `tests/fixtures/test_rules_coverage.py` требует, чтобы каждая запись словарей `api/rules/*.yaml`
сработала хотя бы на одном файле (мёртвый синоним — удалить или добавить синтетику, см. `api/rules/README.md`), а сработала хотя бы на одном файле (мёртвый синоним — удалить или добавить синтетику, см. `api/rules/README.md`), а
`tests/unit/parser/test_no_coordinates.py` — AST-линтер «без координат» в `src/parser/**`. `tests/unit/parser/test_no_coordinates.py` — AST-линтер «без координат» в `src/parser/**`.
@ -153,6 +256,7 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XL
Тип файла по умолчанию определяется по расширению (`.xls`, `.xlsx`, `.xlsb`, регистр не важен); `--file-type` его Тип файла по умолчанию определяется по расширению (`.xls`, `.xlsx`, `.xlsb`, регистр не важен); `--file-type` его
переопределяет, а для неизвестного расширения обязателен. `ENV_FILE=.env.test` нужен, потому что импорт пакета `src` переопределяет, а для неизвестного расширения обязателен. `ENV_FILE=.env.test` нужен, потому что импорт пакета `src`
читает настройки сервиса (`OPENBAO__…`); для разбора они не используются, подойдёт и `api/.env` (см. выше). читает настройки сервиса (`OPENBAO__…`); для разбора они не используются, подойдёт и `api/.env` (см. выше).
Симптом без окружения — `ValidationError: OPENBAO … Field required` при запуске: задайте `ENV_FILE=.env.test`.
В stdout печатается JSON файла результата контракта v1.1 — тот же, что воркер кладёт в S3 под В stdout печатается JSON файла результата контракта v1.1 — тот же, что воркер кладёт в S3 под
`results/{file_id}/{job_id}/result.json` (`job_id` и `file_id` — нулевые заглушки). Формат вывода: UTF-8 без `results/{file_id}/{job_id}/result.json` (`job_id` и `file_id` — нулевые заглушки). Формат вывода: UTF-8 без
@ -225,6 +329,8 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XL
| `WORKER_CONCURRENCY` | `1` | сколько заданий обрабатывается одновременно | | `WORKER_CONCURRENCY` | `1` | сколько заданий обрабатывается одновременно |
| `PUBLISH_RETRIES` | `3` | повторы публикации ack и результата в NATS после первой попытки (пауза 2 с; `0` — без повторов) | | `PUBLISH_RETRIES` | `3` | повторы публикации ack и результата в NATS после первой попытки (пауза 2 с; `0` — без повторов) |
| `DONE_CACHE_SIZE` | `1000` | размер LRU обработанных `job_id` (дедупликация внутри процесса) | | `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__HOST` | `0.0.0.0` | адрес привязки HTTP-сервера |
| `SERVER__PORT` | `8000` | порт HTTP-сервера | | `SERVER__PORT` | `8000` | порт HTTP-сервера |

View File

@ -22,15 +22,17 @@
"header_variant": 1 "header_variant": 1
}, },
"group_schema": [ "group_schema": [
{ "type": "ACCOUNT", "name": "Счет" },
{ "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true }
], ],
"columns": { "columns": {
"opening_debit": "C", "opening_debit": "D",
"opening_credit": "D", "opening_credit": "E",
"turnover_debit": "E", "turnover_debit": "F",
"turnover_credit": "F", "turnover_credit": "G",
"closing_debit": "G", "closing_debit": "H",
"closing_credit": "H" "closing_credit": "I",
"inn": "B"
}, },
"rows": [ "rows": [
{ {
@ -39,7 +41,7 @@
"parent_row_number": null, "parent_row_number": null,
"row_value": "60", "row_value": "60",
"group_type": "ACCOUNT", "group_type": "ACCOUNT",
"group_name": null, "group_name": "Счет",
"account_code_raw": "60", "account_code_raw": "60",
"counterparty_name": null, "counterparty_name": null,
"counterparty_inn": null, "counterparty_inn": null,
@ -55,7 +57,7 @@
"row_number": 2, "row_number": 2,
"level": 1, "level": 1,
"parent_row_number": 1, "parent_row_number": 1,
"row_value": "ООО Лютик, ИНН 7711111111", "row_value": "ООО Лютик",
"group_type": "COUNTERPARTY", "group_type": "COUNTERPARTY",
"group_name": "Контрагенты", "group_name": "Контрагенты",
"account_code_raw": "60", "account_code_raw": "60",
@ -67,7 +69,8 @@
"turnover_debit": "500.00", "turnover_debit": "500.00",
"turnover_credit": "200.50", "turnover_credit": "200.50",
"closing_debit": "0.00", "closing_debit": "0.00",
"closing_credit": "700.50" "closing_credit": "700.50",
"extra": { "КПП": "352801001" }
}, },
{ {
"row_number": 3, "row_number": 3,
@ -77,16 +80,33 @@
"group_type": "OTHER", "group_type": "OTHER",
"group_name": null, "group_name": null,
"account_code_raw": "60", "account_code_raw": "60",
"counterparty_name": "ООО Лютик", "counterparty_name": null,
"counterparty_inn": "7711111111", "counterparty_inn": null,
"data_type": "CURRENCY", "data_type": "CURRENCY",
"opening_debit": "0.00", "opening_debit": "0.00",
"opening_credit": "400.00", "opening_credit": "400.00",
"turnover_debit": "100.00", "turnover_debit": "100.00",
"turnover_credit": "50.00", "turnover_credit": "50.00",
"closing_debit": "0.00", "closing_debit": "0.00",
"closing_credit": "400.00", "closing_credit": "400.00"
"extra": { "КПП": "352801001" } },
{
"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": { "checks": {

View File

@ -8,12 +8,12 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`.
Путь указан относительно `api/` (рабочего каталога команд сервиса): `api/rules/`. Загрузчик — `src/parser/rules.py` Путь указан относительно `api/` (рабочего каталога команд сервиса): `api/rules/`. Загрузчик — `src/parser/rules.py`
(`load_rules()`), источник содержимого — `docs/context/05-domain-rules.md` §1.0–§1.5. (`load_rules()`), источник содержимого — `docs/context/05-domain-rules.md` §1.0–§1.5.
| Файл | Что описывает | | Файл | Ключи | Что описывает |
|---|---| |---|---|---|
| `osv_headers.yaml` | маркер листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `extra` под своим заголовком); строка «Итого»; метки шапки (счёт, период, выводимые данные, валюта, параметры, субконто) | | `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` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) | | `osv_groups.yaml` | `mapping`, `default` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) |
| `periods.yaml` | текст периода -> `period_quarter` и `is_cumulative` (§1.5); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») | | `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end` | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») |
| `data_types.yaml` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.»; коды валют ISO 4217; синонимы валюты (`руб.` -> `RUB`) | | `data_types.yaml` | `header`, `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) |
## Как писать паттерны ## Как писать паттерны
@ -38,13 +38,22 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`.
`groups`, `debit`, `credit`, `service_columns`, `total_row`, `header_markers`, `mapping`, правила и `year`/`end` `groups`, `debit`, `credit`, `service_columns`, `total_row`, `header_markers`, `mapping`, правила и `year`/`end`
периодов, `header`/`row`/`currency_aliases` типов данных) должна сработать хотя бы раз на реальных ОСВ или синтетике: периодов, `header`/`row`/`currency_aliases` типов данных) должна сработать хотя бы раз на реальных ОСВ или синтетике:
это проверяет `tests/fixtures/test_rules_coverage.py` (счётчик совпадений `Pattern`, сообщение — файл, ключ и паттерн это проверяет `tests/fixtures/test_rules_coverage.py` (счётчик совпадений `Pattern`, сообщение — файл, ключ и паттерн
несработавших записей). Перечень `currency_codes` — список значений ISO 4217, а не синонимы, в покрытие не входит. несработавших записей). Перечень `currency_codes` — список значений ISO 4217, а не синонимы, в покрытие не входит (решение R-22).
Несработавшая запись — либо мёртвый синоним (удалить из YAML вместе с тестом словаря), либо пробел в фикстурах Несработавшая запись — либо мёртвый синоним (удалить из YAML вместе с тестом словаря), либо пробел в фикстурах
(добавить файл синтетики `s1x_…` с описанием в README fixtures). Так удалены `service_columns.kpp|code|quantity|currency`: (добавить файл синтетики `s1x_…` с описанием в README fixtures). Так удалены `service_columns.kpp|code|quantity|currency`:
код читает только `inn`, остальные колонки идут в `extra`. Синтетика `s14`–`s16` закрывает остальные записи, которых нет в код читает только `inn`, остальные колонки идут в `extra`. Синтетика `s14`–`s16` закрывает остальные записи, которых нет в
реальных ОСВ (формы ББ/ОФР по одному признаку, названия групп сумм «Входящее/Начальное/Конечное/Исходящее сальдо», реальных ОСВ (формы ББ/ОФР по одному признаку, названия групп сумм «Входящее/Начальное/Конечное/Исходящее сальдо»,
шапка без организации, период «2024», «Детализация: …», группировки `OTHER`). шапка без организации, период «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` не включён: он нужен, чтобы показать, что новый Синоним «нач. остаток» / «начальный остаток» для группы `opening` не включён: он нужен, чтобы показать, что новый

View File

@ -8,40 +8,18 @@
from pathlib import Path from pathlib import Path
import pytest import pytest
from golden_helpers import golden_path, render, snapshot_text, unified_diff from golden_helpers import check_snapshot, golden_path
from src.parser import Parser, get_parser from src.parser import Parser, get_parser
pytestmark = pytest.mark.fixtures pytestmark = pytest.mark.fixtures
MISSING = "нет снимка: выполните make golden-update ({path})"
MAX_DIFF_CHARS = 6000
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def parser() -> Parser: def parser() -> Parser:
return get_parser() return get_parser()
def check_snapshot(fixtures_dir: Path, file: Path, parser: Parser) -> None:
snapshot = golden_path(fixtures_dir, file)
if not snapshot.is_file():
pytest.fail(MISSING.format(path=snapshot.relative_to(fixtures_dir).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_dir).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,
)
def test_osv_file_matches_golden_snapshot(fixtures_dir: Path, osv_file: Path, parser: Parser) -> None: def test_osv_file_matches_golden_snapshot(fixtures_dir: Path, osv_file: Path, parser: Parser) -> None:
check_snapshot(fixtures_dir, osv_file, parser) check_snapshot(fixtures_dir, osv_file, parser)

View File

@ -32,6 +32,7 @@ def test_real_osv_parses_done_with_invariants(osv_file: Path) -> None:
assert outcome.status is ResultStatus.DONE, outcome.metrics assert outcome.status is ResultStatus.DONE, outcome.metrics
(document,) = outcome.documents (document,) = outcome.documents
assert isinstance(document, OsvDocument) assert isinstance(document, OsvDocument)
assert document.locator == "sheet:Лист_1" # spec «Один лист — одна ОСВ»: имя листа берётся из файла, не ищется
checks = document.checks checks = document.checks
assert checks is not None assert checks is not None
assert checks.total_row_matches_root is True assert checks.total_row_matches_root is True

View File

@ -15,6 +15,8 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import pytest
from src.contract import ResultStatus, ServiceInfo from src.contract import ResultStatus, ServiceInfo
from src.core.config import Settings from src.core.config import Settings
from src.core.worker import build_result from src.core.worker import build_result
@ -22,9 +24,11 @@ from src.parser import Parser
from src.parser.cli import STUB_ID, _error_payload, _file_type from src.parser.cli import STUB_ID, _error_payload, _file_type
__all__ = [ __all__ = [
"MISSING",
"SNAPSHOT_DIR", "SNAPSHOT_DIR",
"SyncReport", "SyncReport",
"VOLATILE", "VOLATILE",
"check_snapshot",
"golden_path", "golden_path",
"normalize_result", "normalize_result",
"render", "render",
@ -39,6 +43,8 @@ OSV_DIR = "osv"
JSON_INDENT = 2 JSON_INDENT = 2
# Поля, которые меняются от запуска к запуску: идентификаторы заглушек, версия сервиса, фактическая длительность # Поля, которые меняются от запуска к запуску: идентификаторы заглушек, версия сервиса, фактическая длительность
VOLATILE: tuple[str | tuple[str, str], ...] = ("job_id", "file_id", ("service", "version"), ("metrics", "duration_ms")) 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]: def normalize_result(result: dict[str, Any]) -> dict[str, Any]:
@ -145,3 +151,23 @@ def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, *
if write: if write:
stale.unlink() stale.unlink()
return report return report
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,
)

View File

@ -4,7 +4,7 @@ import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
from golden_helpers import golden_path, render, snapshot_text, sync_snapshots from golden_helpers import check_snapshot, golden_path, render, snapshot_text, sync_snapshots
from src.parser import get_parser from src.parser import get_parser
from tests.synthetic.generate import generate_all from tests.synthetic.generate import generate_all
@ -91,6 +91,31 @@ def test_orphan_snapshot_is_reported_and_removed_on_write(root: Path) -> None:
assert not orphan.exists() assert not orphan.exists()
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( def test_script_check_exit_codes(
root: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] root: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None: ) -> None:

View File

@ -434,10 +434,10 @@ def render(parser, file: Path) -> dict # как CLI: RecognitionResult
- 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: `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]`) - 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]`)
- [ ] **Step 1:** таблица «требование спеки `osv-parsing` → сценарий → тест» в отчёте задачи; каждый сценарий имеет тест (иначе добавить). - [x] **Step 1:** таблица «требование спеки `osv-parsing` → сценарий → тест» в отчёте задачи; каждый сценарий имеет тест (иначе добавить).
- [ ] **Step 2:** документы по списку; `openspec validate --strict` в обоих репозиториях. - [x] **Step 2:** документы по списку; `openspec validate --strict` в обоих репозиториях.
- [ ] **Step 3:** `make check-full` (compose поднят: `make up`; MinIO бакет `arm-ka` есть — проверить `test_s3_integration`, OQ-80) → всё зелёное, вывод в ledger; `pytest -m fixtures` → 66 + синтетика + golden зелёные. - [x] **Step 3:** `make check-full` (compose поднят: `make up`; MinIO бакет `arm-ka` есть — проверить `test_s3_integration`, OQ-80) → всё зелёное, вывод в ledger; `pytest -m fixtures` → 66 + синтетика + golden зелёные.
- [ ] **Step 4:** коммиты: здесь `docs: README/CLAUDE.md Среза 2a, tasks.md`; в docs `docs: §1 по факту парсера, 06-test-data, план срезов 2a–2d, OQ`. - [x] **Step 4:** коммиты: здесь `docs: README/CLAUDE.md Среза 2a, tasks.md`; в docs `docs: §1 по факту парсера, 06-test-data, план срезов 2a–2d, OQ`.
--- ---

View File

@ -24,41 +24,50 @@
## Decisions ## Decisions
### D-1. Единая модель листа `Sheet`, читатели — адаптеры ### D-1. Единая модель листа `Sheet`, читатели — адаптеры
`grid.py`: `Cell(value: str | Decimal | datetime | None, kind: Literal["text","number","date","empty"], bold: bool, `grid.py`: `Cell(value: str | Decimal | date | datetime | bool | None, kind: Literal["empty","text","number","date","bool"],
indent: int)`, `Sheet(name, cells: dict[tuple[int,int], Cell], merged: tuple[MergedRange, ...], row_outline: dict[int,int], bold: bool, indent: int)`, `Sheet(name, cells: Mapping[(row, col), Cell], merged: tuple[MergedRange, ...], row_outline:
n_rows, n_cols)` с методами `row(r) -> list[Cell]`, `text(r,c)`, `merged_origin(r,c)`, `nonempty_rows()`. Индексы Mapping[int, int], n_rows, n_cols)`. Хранятся только непустые ячейки; ячейки из одних пробелов не хранятся в индексе строк.
1-based, как в Excel, для диагностики (`row_number` контракта). Читатели (`readers/xlsx.py`, `xls.py`, `xlsb.py`) Методы: `cell(r, c)`, `text_at(r, c)` (внутри merged — текст origin), `merged_origin(r, c)`, `merged_span(r, c)`,
возвращают `tuple[Sheet, ...]`; числа сразу в `Decimal` (`str(float)` → `Decimal` с квантованием до 2 знаков только `row_cells(r)` (непустые `(col, cell)` по возрастанию колонки), `is_row_empty(r)`, `outline(r)`. Индексы 1-based, как в
для сумм, на уровне `numbers.py`). *Альтернатива* — работать напрямую с объектами openpyxl/xlrd: три разных API в 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`, результат — индексы, а не буквы ### D-2. Детекторы — чистые функции над `Sheet`, результат — индексы, а не буквы
`detect.find_osv_sheets(book, rules) -> list[Sheet]`; `detect.find_table_header(sheet, rules) -> TableHeader(rows: range, `detect.classify_sheet(sheet, rules) -> SheetKind` (ОСВ / форма ББ-ОФР / прочее); `detect.find_table_header(sheet, rules)
groups: {opening, turnover, closing} -> ColumnSpan, debit_credit: ColumnMap(six indexes | None), text_col, inn_col | None, -> TableHeader(first_row, last_row, columns: ColumnMap(шесть индексов; `None` у «начала», если его нет), text_col,
right_edge)`. Поиск групп — по тексту ячеек в пределах 1–4 подряд идущих строк с учётом merged (значение берётся из inn_col | None, right_edge, extra_cols, group_label_rows)`. Поиск групп — по тексту ячеек в пределах 1–4 подряд идущих
origin), «Дебет/Кредит» ищутся в следующей строке внутри span группы; при отсутствии «начала» соответствующие индексы строк с учётом merged (значение берётся из origin), «Дебет/Кредит» ищутся в следующей строке внутри span группы. **Строка
`None`; одна колонка в группе без Дебет/Кредит → свёрнутое сальдо (знак = сторона) с предупреждением. Всё правее групп обязана содержать «обороты» и «сальдо на конец»; отсутствовать может только «начало»** (R-12, design №22):
`right_edge` игнорируется (§1.2). В `columns` контракта пишутся буквы колонок **только как диагностика** (функция кандидат без них пропускается, при отсутствии подходящего — `INVALID_FORMAT` с названием недостающей группы. Одна колонка
`column_letter(index)` в `grid.py`), код парсера по буквам ничего не ищет. в группе без Дебет/Кредит → свёрнутое сальдо (знак = сторона) с предупреждением. Всё правее `right_edge` игнорируется
(§1.2). В `columns` контракта пишутся буквы колонок **только как диагностика** (функция `column_letter(index)` в
`grid.py`), код парсера по буквам ничего не ищет.
### D-3. Каскад стратегий иерархии ### D-3. Каскад стратегий иерархии
`hierarchy.levels(sheet, body_rows, header) -> Levels(by_row: dict[int,int], strategy: str)`. Порядок: `outline` `hierarchy.levels(sheet, body_rows, header) -> Levels(by_row: dict[int,int], strategy: str)`. Порядок: `outline`
(если ≥ 2 различных уровней среди строк данных) → `indent` (`Cell.indent` или ведущие пробелы, ≥ 2 различных) → (если ≥ 2 различных уровней среди строк данных) → `indent` (`Cell.indent` или ведущие пробелы, ≥ 2 различных) →
`account_pattern` (строка-счёт ранга по числу сегментов кода, не-счёт = ранг последнего счёта + 1 + глубина по жирности) `account_pattern` (строка-счёт ранга по числу сегментов кода, не-счёт = ранг последнего счёта + 1 + глубина по жирности)
→ `sums` (строка — родитель следующих, пока сумма детей не совпадёт по шести колонкам). Первая стратегия, давшая → `sums` (строка — родитель следующих, пока сумма детей не совпадёт по шести колонкам). Первая стратегия, давшая
непротиворечивое дерево (каждая строка имеет родителя с меньшим уровнем, «Итого» level 0), побеждает; имя — в непротиворечивое дерево (каждая строка имеет родителя с меньшим уровнем), побеждает; имя — в `metrics.warnings` как
`metrics.warnings` как `hierarchy=<strategy>` всегда (чтобы golden фиксировал стратегию). *Цена ошибки:* порядок `hierarchy=<strategy>` всегда (чтобы golden фиксировал стратегию). Строка «Итого» стратегиям **не передаётся**: она всегда
уровень 0 без родителя (R-14, design №24). Если строк данных (кроме «Итого») меньше двух — стратегия `trivial` (дерево из
корней; пустая ОСВ, R-13, design №23); `outline` у всех файлов со строками данных, `trivial` — у пустых. *Цена ошибки:* порядок
стратегий меняется в одном списке. стратегий меняется в одном списке.
### D-4. Классификация и `group_schema` ### D-4. Классификация и `group_schema`
`header.group_schema(sheet, table_header, rules)`: вариант 1 — строки первой колонки блока заголовка сверху вниз, `header.group_schema(sheet, table_header, rules)`: вариант 1 — строки первой колонки блока заголовка сверху вниз,
первая всегда ACCOUNT; вариант 2 — «субконто: A, B» через запятую после первого ACCOUNT; ячейка `ИНН` в строке первая всегда ACCOUNT; вариант 2 — «субконто: A, B» через запятую после первого ACCOUNT; ячейка `ИНН` в строке
группировки → `has_inn`. `classify.rows(...)`: счёт по `^\d{2,3}(\.\d{1,2}){0,2}(,\s.*)?$`, нормализация кода (`60.1 → группировки → `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` остаётся как в файле (контракт), нормализованный код — забота ядра; не-счёт → группа по 60.01`) в `account_code_raw` остаётся как в файле (контракт), нормализованный код — забота ядра; не-счёт → группа по
позиции (количество не-счётных предков после последнего ACCOUNT); для COUNTERPARTY `counterparty_name = row_value`, позиции (количество не-счётных предков после последнего ACCOUNT); для COUNTERPARTY `counterparty_name = row_value`,
`counterparty_inn` из колонки ИНН (только цифры, 10/12, иначе `null`); строки «Вал.», «Кол.», трёхбуквенные коды валют → `counterparty_inn` — только из колонки ИНН (цифры из текстовой ячейки, результат при 10 или 12 цифрах, иначе `null`; ИНН
`data_type` CURRENCY/QUANTITY; их суммы остаются в шести полях, `data_type` лишь помечает строку, и в инвариантах §1.4 внутри текста строки не извлекается — R-15, R-17); строки «Вал.», «Кол.», трёхбуквенные коды валют → `group_type` OTHER,
такие строки исключаются. Последняя строка level 0 с текстом из словаря «Итого» → `TOTAL`. `data_type` CURRENCY/QUANTITY, без позиции в схеме и без `counterparty_*` (`account_code_raw` наследуется от ветки, R-19);
их суммы остаются в шести полях, `data_type` лишь помечает строку, и в инвариантах §1.4 такие строки исключаются. Последняя строка level 0 с текстом из словаря «Итого» → `TOTAL`.
### D-5. Числа ### D-5. Числа
`numbers.to_money(cell) -> Decimal | None`: число → `Decimal(str(value)).quantize(0.01)`; текст → убрать пробелы (в т.ч. `numbers.to_money(cell) -> Decimal | None`: число → `Decimal(str(value)).quantize(0.01)`; текст → убрать пробелы (в т.ч.
@ -70,11 +79,14 @@ NBSP, узкие), запятую → точку, `(x)` → `-x`, прочерк
`error INVALID_FORMAT` (с указанием шага); книга не открывается → `UNREADABLE`; расширение/сигнатура не поддерживаются → `error INVALID_FORMAT` (с указанием шага); книга не открывается → `UNREADABLE`; расширение/сигнатура не поддерживаются →
`UNSUPPORTED_FORMAT`; размер > `PARSE_MAX_BYTES` → `UNSUPPORTED_FORMAT` с текстом про лимит; дедлайн превышен → `TIMEOUT`; `UNSUPPORTED_FORMAT`; размер > `PARSE_MAX_BYTES` → `UNSUPPORTED_FORMAT` с текстом про лимит; дедлайн превышен → `TIMEOUT`;
любое непредвиденное исключение → `RECOGNITION_FAILED`. Инварианты §1.4 нарушены хотя бы на одном листе → `needs_check` любое непредвиденное исключение → `RECOGNITION_FAILED`. Инварианты §1.4 нарушены хотя бы на одном листе → `needs_check`
всего результата, номера строк в `checks`, причины в `warnings`. Несколько листов ОСВ → несколько документов, лист с всего результата, номера строк в `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=<name> skipped: statement (slice 2b)`. признаками ББ/ОФР (§12 п. 1) → пропуск с предупреждением `sheet=<name> skipped: statement (slice 2b)`.
### D-7. Словари — pydantic-модели из YAML, загружаются один раз ### D-7. Словари — pydantic-модели из YAML, загружаются один раз
`rules/loader.py: load_rules(path) -> Rules` (frozen), кэш в `get_parser()`. Регулярки компилируются с `re.IGNORECASE` `rules.py: load_rules(path) -> Rules` (frozen); кэша в `get_parser()` нет: парсер создаётся один раз при старте
приложения, ошибка словаря — падение старта. Регулярки компилируются с `re.IGNORECASE`
после нормализации «ё→е» и схлопывания пробелов/переносов обеих сторон. Тест `test_rules_coverage`: каждая запись после нормализации «ё→е» и схлопывания пробелов/переносов обеих сторон. Тест `test_rules_coverage`: каждая запись
словаря матчится хотя бы одной фикстурой или синтетикой (иначе запись мёртвая — либо удалить, либо добавить синтетику). словаря матчится хотя бы одной фикстурой или синтетикой (иначе запись мёртвая — либо удалить, либо добавить синтетику).
@ -94,8 +106,8 @@ Property-тест параметризован по всем файлам `../fi
равен коду корневой строки; `period_quarter` распознан; хотя бы один документ. Golden: `result.json` без `job_id`, равен коду корневой строки; `period_quarter` распознан; хотя бы один документ. Golden: `result.json` без `job_id`,
`file_id`, `service.version`, `metrics.duration_ms`; каталог зеркалит `fixtures/osv/`; обновление — `make golden-update` `file_id`, `service.version`, `metrics.duration_ms`; каталог зеркалит `fixtures/osv/`; обновление — `make golden-update`
(перезапись + вывод diff), отдельный коммит в fixtures. Синтетика: `tests/synthetic/generate.py` детерминированно пишет (перезапись + вывод diff), отдельный коммит в fixtures. Синтетика: `tests/synthetic/generate.py` детерминированно пишет
xlsx в `../fixtures/osv/synthetic/`; xlsb — один файл, сконвертированный вручную из синтетического xlsx (LibreOffice) xlsx в `../fixtures/osv/synthetic/`; xlsb — один файл `s13`, собранный тестовым писателем BIFF12 (`tests/synthetic/xlsb_writer.py`; LibreOffice нет, R-9),
и закоммиченный. Линтер `test_no_coordinates.py`: AST `src/parser/**` кроме `grid.py` и `readers/` — запрещены строковые а не сконвертированный в Excel: совместимость с настоящим xlsb не доказана (O-7). Линтер `test_no_coordinates.py`: AST `src/parser/**` кроме `grid.py` и `readers/` — запрещены строковые
литералы `^[A-Z]{1,3}\d*$` длиной ≤ 4, литералы с «Лист», сравнения `row == <int>` / индексация `[<int>]` по `Sheet`. литералы `^[A-Z]{1,3}\d*$` длиной ≤ 4, литералы с «Лист», сравнения `row == <int>` / индексация `[<int>]` по `Sheet`.
## Risks / Trade-offs ## Risks / Trade-offs
@ -115,8 +127,10 @@ xlsx в `../fixtures/osv/synthetic/`; xlsb — один файл, сконвер
## Open Questions ## Open Questions
Не блокируют: OQ-02 (нарастающие по данным — ядро), OQ-45 (`NU` в ядре), OQ-01 (хранение «Итого»), OQ-73 (дедуп по Не блокируют: OQ-02 (нарастающие по данным — ядро), OQ-45 (`NU` в ядре), OQ-01 (хранение «Итого»), OQ-73 (дедуп по
attempt), OQ-81 (закрывается хелпером фикстур). Новый вопрос в docs: единый список кодов валют для строк `data_type = attempt), OQ-81 (закрыт хелпером фикстур tabular-parser). Новые вопросы внесены в `07-open-questions.md` задачей 13
CURRENCY` (словарь `data_types.yaml` — ISO 4217 буквенные коды, достаточно ли?). (OQ-98…OQ-105: коды валют CURRENCY-строк, несколько ОСВ одного счёта в книге, ИНН внутри текста строки и «ИНН/КПП»,
числа с «+»/«.5»/«5.», xlsb из Excel, буквенный первый сегмент счёта, «Валюта» в строке детализации, подтверждение
сверки по нетто).
## Decisions made without discussion ## Decisions made without discussion
@ -154,3 +168,5 @@ CURRENCY` (словарь `data_types.yaml` — ISO 4217 буквенные ко
| 30 | 2026-10-05 | R-20: если строка «Итого» не найдена — предупреждение «строка «Итого» не найдена» и `checks.total_row_matches_root = false` (→ needs_check) | сверку с корнем выполнить нельзя; консервативно — на проверку аналитику | файл без «Итого» всегда needs_check | | 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 | перестановка сторон при совпавшем нетто родителя не ловится этой проверкой (ловят тождество строки и «Итого = корень») | | 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 нельзя навязать фикстурам | мёртвые коды валют остаются в списке | | 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` в обоих случаях; дефект принят, не исправлен | малое влияние, правка требует различать область шапки и строку детализации | ложное предупреждение у ОСВ с группировкой «Валюта» |

View File

@ -39,8 +39,9 @@
иерархия, классификация, числа, контрольные суммы, статусы и диагностика, словари, лимиты. иерархия, классификация, числа, контрольные суммы, статусы и диагностика, словари, лимиты.
### Modified Capabilities ### Modified Capabilities
- `tabular-recognition-service`: требование «Парсер — заменяемый интерфейс» — реализация по умолчанию разбирает ОСВ, - `tabular-recognition-service`: требования «Парсер — заменяемый интерфейс» и «Результат по контракту» — реализация по
`NOT_IMPLEMENTED` остаётся только для ББ/ОФР-листов (§12, следующий подсрез); результат пишется с `schema_version 1.1`. умолчанию разбирает ОСВ, листы ББ/ОФР пропускаются с предупреждением (§12, следующий подсрез), `NOT_IMPLEMENTED`
остаётся только у заглушки `NullParser` в тестах воркера; результат пишется с `schema_version 1.1`.
## Impact ## Impact

View File

@ -10,7 +10,8 @@
### Requirement: Листы ОСВ находятся по содержимому ### Requirement: Листы ОСВ находятся по содержимому
Парсер SHALL считать листом ОСВ любой лист, где хотя бы одна ячейка матчит признак ОСВ из `rules/osv_headers.yaml` Парсер SHALL считать листом ОСВ любой лист, где хотя бы одна ячейка матчит признак ОСВ из `rules/osv_headers.yaml`
(без учёта регистра, «ё/е», переносов строк и дефисов). Листы без признаков ОСВ SHALL пропускаться с предупреждением (без учёта регистра, «ё/е», переносов строк и дефисов). Листы без признаков ОСВ SHALL пропускаться с предупреждением
в `metrics.warnings`; лист с признаками ББ/ОФР (§12 п. 1) — с предупреждением о подсрезе 2b. Книга без единого листа ОСВ → `sheet=<имя> skipped: нет признаков ОСВ` в `metrics.warnings`; лист с признаками ББ/ОФР (§12 п. 1) — с предупреждением
`sheet=<имя> skipped: statement (slice 2b)`. Книга без единого листа ОСВ →
`status = error`, `error.code = INVALID_DOCUMENT`. Несколько листов ОСВ → несколько `documents` с `locator = sheet:<имя>`. `status = error`, `error.code = INVALID_DOCUMENT`. Несколько листов ОСВ → несколько `documents` с `locator = sheet:<имя>`.
Имя листа MUST NOT использоваться для поиска. Имя листа MUST NOT использоваться для поиска.
@ -29,13 +30,14 @@
### Requirement: Заголовок таблицы и колонки сумм определяются по тексту ### Requirement: Заголовок таблицы и колонки сумм определяются по тексту
Парсер SHALL найти блок заголовка таблицы: 1–4 подряд идущих строки, в которых (с учётом объединённых ячеек) встречаются Парсер SHALL найти блок заголовка таблицы: 1–4 подряд идущих строки, в которых (с учётом объединённых ячеек) встречаются
группы «сальдо на начало», «обороты за период», «сальдо на конец» по синонимам словаря, и под каждой группой подзаголовки группы «сальдо на начало», «обороты за период», «сальдо на конец» по синонимам словаря, и под каждой группой подзаголовки
«Дебет»/«Кредит» по синонимам. Допустимы: отсутствие группы «начало» (сальдо на начало `null`), одна колонка в группе без «Дебет»/«Кредит» по синонимам. Группы «обороты» и «сальдо на конец» обязательны: строка, где их нет, заголовком не
считается (R-12). Допустимы: отсутствие группы «начало» (сальдо на начало `null`), одна колонка в группе без
Дебет/Кредит (свёрнутое сальдо: положительное — дебет, отрицательное — кредит, предупреждение), дополнительные колонки Дебет/Кредит (свёрнутое сальдо: положительное — дебет, отрицательное — кредит, предупреждение), дополнительные колонки
(«Количество», «Валюта», «ИНН», «КПП», «Код»). Колонка текста строки — первая непустая слева от сумм по строкам данных; («Количество», «Валюта», «ИНН», «КПП», «Код»). Колонка текста строки — первая непустая слева от сумм по строкам данных;
колонка ИНН — по заголовку `ИНН` или колонка, где ≥ 80 % непустых значений — 10 или 12 цифр. Правая граница таблицы — колонка ИНН — по заголовку `ИНН` или колонка, где ≥ 80 % непустых значений — 10 или 12 цифр. Правая граница таблицы —
последняя колонка «Кредит»; всё правее MUST NOT попадать в суммы. Колонки внутри таблицы, не сопоставленные с шестью последняя колонка «Кредит»; всё правее MUST NOT попадать в суммы. Колонки внутри таблицы, не сопоставленные с шестью
суммами и ИНН, SHALL попадать в `rows[].extra` по заголовку. Заголовок не найден или найдено меньше двух групп → `status = суммами и ИНН, SHALL попадать в `rows[].extra` по заголовку. Заголовок не найден или в найденной строке групп нет «оборотов» либо «сальдо
error`, `error.code = INVALID_FORMAT` с названием ненайденной группы. В `columns` SHALL быть записана диагностика (буква на конец» → `status = error`, `error.code = INVALID_FORMAT` с названием ненайденной группы, листом и шагом. В `columns` SHALL быть записана диагностика (буква
колонки) для каждой из шести сумм и ИНН. колонки) для каждой из шести сумм и ИНН.
#### Scenario: Плавающие колонки двух организаций #### Scenario: Плавающие колонки двух организаций
@ -85,31 +87,44 @@ error`, `error.code = INVALID_FORMAT` с названием ненайденно
### Requirement: Иерархия строк — каскад стратегий ### Requirement: Иерархия строк — каскад стратегий
Парсер SHALL определять `level` строк данных первой подходящей стратегией из списка: outline level Excel; отступ ячейки Парсер SHALL определять `level` строк данных первой подходящей стратегией из списка: outline level Excel; отступ ячейки
или ведущие пробелы; шаблон кода счёта и жирность; равенство «сумма детей = родитель». Стратегия подходит, если даёт дерево, или ведущие пробелы; шаблон кода счёта и жирность; равенство «сумма детей = родитель». Стратегия подходит, если даёт дерево,
где у каждой строки есть предок с меньшим уровнем (кроме уровня 0) и строка «Итого» имеет уровень 0. Выбранная стратегия где у каждой строки есть предок с меньшим уровнем (кроме уровня 0). Строка «Итого» стратегиям MUST NOT передаваться:
SHALL быть записана в `metrics.warnings` как `hierarchy=<имя>`. `parent_row_number` SHALL указывать на ближайшую она всегда имеет уровень 0 и не имеет родителя (R-14). Если строк данных (кроме «Итого») меньше двух (пустая ОСВ или одна
строка) — стратегия `trivial`: дерево из корней (R-13). Выбранная стратегия SHALL быть записана в `metrics.warnings` как
`sheet=<имя> hierarchy=<имя стратегии>`. `parent_row_number` SHALL указывать на ближайшую
предыдущую строку с меньшим уровнем. предыдущую строку с меньшим уровнем.
#### Scenario: Outline level (реальные файлы) #### Scenario: Outline level (реальные файлы)
- **WHEN** разбирается `rb/2024-FY/ОСВ_66 за 2024.xls` - **WHEN** разбирается `rb/2024-FY/ОСВ_66 за 2024.xls` (и любой другой реальный файл со строками данных)
- **THEN** `hierarchy=outline`, строки `66` (0) → `66.01` (1) → контрагент (2); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» - **THEN** `hierarchy=outline`, строки `66` (0) → `66.01` (1) → контрагент (2); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ»
`parent_row_number` — строка `66.03` `parent_row_number` — строка `66.03`
#### Scenario: Без outline level #### Scenario: Без outline level
- **WHEN** разбирается синтетическая копия той же ОСВ без группировок Excel, с отступами ячеек - **WHEN** разбирается синтетическая ОСВ без группировок Excel, с отступами ячеек (`s02`)
- **THEN** `hierarchy=indent`, дерево и суммы совпадают с оригиналом - **THEN** `hierarchy=indent`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по
кодам счетов и жирности (`s03`)
#### Scenario: Без outline и отступов #### Scenario: Без outline и отступов
- **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности - **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`)
- **THEN** `hierarchy=account_pattern`, инварианты §1.4 выполнены - **THEN** `hierarchy=account_pattern`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт
#### Scenario: Пустая ОСВ — тривиальное дерево
- **WHEN** в ОСВ нет строк данных кроме «Итого»
- **THEN** `hierarchy=trivial`, «Итого» — уровень 0 без родителя
### Requirement: Классификация строк ### Requirement: Классификация строк
Каждая строка данных SHALL получить `group_type`: ACCOUNT — текст матчит `^\d{2,3}(\.\d{1,2}){0,2}(,\s.*)?$` Каждая строка данных SHALL получить `group_type`: ACCOUNT — текст матчит `ACCOUNT_RE` (регистр учитывается, IGNORECASE
(`account_code_raw` — код как в файле, без названия после запятой); не-счёт — группировка из `group_schema` по позиции нет): две-три цифры и до двух сегментов субсчёта через точку, где сегмент — `\d{1,2}` **или 1–3 заглавные буквы**
(число не-счётных предков после последнего ACCOUNT + 1), при выходе за схему — OTHER; последняя строка уровня 0 с текстом кириллицы/латиницы («76.АВ», «76.ВА», «76.ОТ», R-16), далее опционально запятая и название: `^\d{2,3}(\.(\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2}(,\s*.*)?$`
из словаря «Итого» — TOTAL. Для COUNTERPARTY `counterparty_name = row_value`, `counterparty_inn` — только цифры длиной 10 (после запятой допустим любой пробел или его отсутствие, R-18); `account_code_raw` — код как в файле, без названия после
или 12 из колонки ИНН, иначе `null`. Строки «Вал.», «Кол.», трёхбуквенного кода валюты SHALL получать `data_type` запятой. Не-счёт — группировка из `group_schema` по позиции (число не-счётных предков после последнего ACCOUNT + 1), при
CURRENCY/QUANTITY (суммы остаются в шести полях), остальные — `data_type` шапки. Все строки, включая TOTAL, SHALL выходе за схему — OTHER; последняя строка уровня 0 с текстом из словаря «Итого» — TOTAL. Для COUNTERPARTY
присутствовать в `rows` (`ASSUMPTION(OQ-01)`: хранить ли «Итого», решает ядро). `counterparty_name = row_value`, `counterparty_inn` — **только** из колонки ИНН: цифры текстовой ячейки колонки («ИНН
352819549855» → `352819549855», R-15), результат только при 10 или 12 цифрах, иначе `null`; ИНН внутри текста строки
(«Полиграф, 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: Подразделение, контрагент с ИНН, физлицо #### Scenario: Подразделение, контрагент с ИНН, физлицо
- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls` - **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls`
@ -120,9 +135,14 @@ CURRENCY/QUANTITY (суммы остаются в шести полях), ост
- **WHEN** в синтетической ОСВ строка «60.01, Расчеты с поставщиками» - **WHEN** в синтетической ОСВ строка «60.01, Расчеты с поставщиками»
- **THEN** `group_type = ACCOUNT`, `account_code_raw = "60.01"`, `row_value` — исходный текст - **THEN** `group_type = ACCOUNT`, `account_code_raw = "60.01"`, `row_value` — исходный текст
#### Scenario: Буквенный субсчёт
- **WHEN** в ОСВ 76 строка «76.АВ» (реальные файлы РБ и Счастливочной) либо «60.АВ, …» (синтетика)
- **THEN** `group_type = ACCOUNT`, строка наследуется детьми как счёт ветки; «76.ав» (строчные) счётом не считается
#### Scenario: Строки Вал./Кол. #### Scenario: Строки Вал./Кол.
- **WHEN** в синтетической ОСВ под контрагентом строки «USD» и «Кол.» - **WHEN** в синтетической ОСВ под контрагентом строки «USD» и «Кол.»
- **THEN** `data_type = CURRENCY` и `QUANTITY`, они исключены из проверки «сумма детей = родитель», статус `done` - **THEN** `group_type = OTHER`, `data_type = CURRENCY` и `QUANTITY`, `counterparty_name`/`counterparty_inn` равны `null`,
`account_code_raw` совпадает с родителем, строки исключены из проверки «сумма детей = родитель», статус `done`
### Requirement: Числа — Decimal, пусто — null ### Requirement: Числа — Decimal, пусто — null
Суммы SHALL приводиться к `Decimal` с двумя знаками: числовые ячейки — через строковое представление; текстовые — Суммы SHALL приводиться к `Decimal` с двумя знаками: числовые ячейки — через строковое представление; текстовые —
@ -137,7 +157,13 @@ CURRENCY/QUANTITY (суммы остаются в шести полях), ост
### Requirement: Контрольные суммы определяют статус ### Requirement: Контрольные суммы определяют статус
Для каждого документа парсер SHALL проверить: по каждой строке `opening_debit − opening_credit + turnover_debit − Для каждого документа парсер SHALL проверить: по каждой строке `opening_debit − opening_credit + turnover_debit −
turnover_credit = closing_debit − closing_credit` (±0.01, `null` = 0); по каждому родителю суммы = Σ детей с тем же turnover_credit = closing_debit − closing_credit` (±0.01, `null` = 0); по каждому родителю суммы = Σ детей с тем же
`data_type` по шести колонкам (±0.01); строка TOTAL = корневая строка ACCOUNT. Результат — `checks{total_row_matches_root, `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` всего результата и запись balance_identity_violations, parent_sum_violations}`. Любое нарушение → `status = needs_check` всего результата и запись
причины в `metrics.warnings`; парсер MUST NOT подгонять структуру под сходимость. причины в `metrics.warnings`; парсер MUST NOT подгонять структуру под сходимость.
@ -150,10 +176,21 @@ balance_identity_violations, parent_sum_violations}`. Любое нарушен
- **THEN** `status = needs_check`, `balance_identity_violations` содержит его `row_number`, `parent_sum_violations` — - **THEN** `status = needs_check`, `balance_identity_violations` содержит его `row_number`, `parent_sum_violations` —
`row_number` родителя, в `warnings` — текст с номерами строк `row_number` родителя, в `warnings` — текст с номерами строк
#### Scenario: Сальдо родителя свёрнуто нетто
- **WHEN** у родителя сальдо показано одной стороной (нетто), а у детей — обеими сторонами, и нетто детей равно нетто родителя
- **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто
или расхождение оборотов — нарушение
#### Scenario: «Итого» не найдена
- **WHEN** в таблице нет строки с текстом из словаря «Итого»
- **THEN** `total_row_matches_root = false`, `status = needs_check`, в `warnings` — «строка «Итого» не найдена»
#### Scenario: Пустая ОСВ #### Scenario: Пустая ОСВ
- **WHEN** разбирается `schastl/2025-Q2/…66 за 2 квартал 2025 г..xlsx` (заголовок и сразу «Итого») - **WHEN** разбираются реальные пустые ОСВ (в фикстурах две: `schastl/2025-Q2/…58 за 2 квартал 2025 г..xlsx` и `…66 за 2
квартал 2025 г..xlsx`: заголовок и сразу «Итого») либо любой файл, где кроме «Итого» строк данных нет
- **THEN** `status = done`, `rows` содержит только строку TOTAL, `metrics.rows = 0` (строки данных без TOTAL), - **THEN** `status = done`, `rows` содержит только строку TOTAL, `metrics.rows = 0` (строки данных без TOTAL),
`checks.total_row_matches_root = true` (корня нет, суммы TOTAL пусты), предупреждение «нет строк данных» `hierarchy=trivial`, `checks.total_row_matches_root = true` (корня нет, суммы TOTAL пусты), предупреждение
«нет строк данных»
### Requirement: Форматы, лимиты, время ### Requirement: Форматы, лимиты, время
Парсер SHALL читать XLSX (openpyxl, полное чтение), XLS (xlrd, `formatting_info`), XLSB (pyxlsb; outline level недоступен — Парсер SHALL читать XLSX (openpyxl, полное чтение), XLS (xlrd, `formatting_info`), XLSB (pyxlsb; outline level недоступен —
@ -164,8 +201,9 @@ balance_identity_violations, parent_sum_violations}`. Любое нарушен
`fixtures/osv/**` SHALL разбираться быстрее 5 с. `fixtures/osv/**` SHALL разбираться быстрее 5 с.
#### Scenario: XLSB #### Scenario: XLSB
- **WHEN** разбирается синтетический xlsb той же ОСВ - **WHEN** разбирается синтетический xlsb той же ОСВ, что `s02` (`s13`, собран тестовым писателем BIFF12, а не Excel — O-7)
- **THEN** результат совпадает с xlsx-оригиналом во всём, кроме `hierarchy=indent` и `locator` - **THEN** результат совпадает с xlsx-оригиналом во всём, кроме `locator` и предупреждения «xlsb: outline level
недоступен»; `hierarchy=indent`
#### Scenario: Расширение не совпадает с содержимым #### Scenario: Расширение не совпадает с содержимым
- **WHEN** запрос говорит `XLSX`, а байты — BIFF8 - **WHEN** запрос говорит `XLSX`, а байты — BIFF8
@ -176,21 +214,29 @@ balance_identity_violations, parent_sum_violations}`. Любое нарушен
- **THEN** `error UNSUPPORTED_FORMAT`, книга не открывается - **THEN** `error UNSUPPORTED_FORMAT`, книга не открывается
### Requirement: Словари — данные, не код ### Requirement: Словари — данные, не код
Признаки листов, синонимы групп колонок и подзаголовков, служебных колонок, маркеров шапки (`rules/osv_headers.yaml`), Признаки листов, синонимы групп колонок и подзаголовков, колонки ИНН, строки «Итого», маркеров шапки (`rules/osv_headers.yaml`:
группировок (`rules/osv_groups.yaml`), периодов (`rules/periods.yaml`), типов данных и кодов валют (`rules/data_types.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-моделью при старте (ошибка словаря — падение старта с указанием файла и SHALL загружаться из YAML и валидироваться pydantic-моделью при старте (ошибка словаря — падение старта с указанием файла и
записи). В `src/parser/` (кроме `grid.py` и `readers/`) MUST NOT быть строковых литералов букв колонок, адресов ячеек, записи). В `src/parser/` (кроме `grid.py` и `readers/`) MUST NOT быть строковых литералов букв колонок, адресов ячеек,
имён листов и сравнений с номерами строк — проверяется тестом по AST. Каждая запись словаря SHALL срабатывать хотя бы на имён листов и сравнений с номерами строк — проверяется тестом по AST. Каждая запись распознавания словаря (паттерн) SHALL
одной фикстуре или синтетике (тест покрытия словарей). срабатывать хотя бы на одной фикстуре или синтетике (тест покрытия словарей, R-22); перечень кодов валют
`currency_codes` (ISO 4217) — список значений, а не синонимы, и в покрытие не входит.
#### Scenario: Новый синоним без релиза кода #### Scenario: Новый синоним без релиза кода
- **WHEN** в `osv_headers.yaml` добавлен синоним «Нач. остаток» и дана синтетическая ОСВ с таким заголовком - **WHEN** в `osv_headers.yaml` добавлен синоним «Нач. остаток» и дана синтетическая ОСВ с таким заголовком
- **THEN** разбор проходит без изменений в `src/` - **THEN** разбор проходит без изменений в `src/`; без этого синонима заголовок «Нач. остаток» не распознаётся
(колонки начала уходят в `extra`, результат `needs_check`)
#### Scenario: Литерал координаты #### Scenario: Литерал координаты
- **WHEN** в `detect.py` появляется строка `"E9"` - **WHEN** в `detect.py` появляется строка `"E9"`
- **THEN** тест-линтер падает с указанием файла и строки - **THEN** тест-линтер падает с указанием файла и строки
#### Scenario: Мёртвая запись словаря
- **WHEN** в словарь добавлена запись, не срабатывающая ни на одном реальном или синтетическом файле
- **THEN** тест покрытия падает с указанием файла, ключа и паттерна
### Requirement: Золотые снимки и property-тесты ### Requirement: Золотые снимки и property-тесты
Для каждого файла `fixtures/osv/**` SHALL существовать снимок `fixtures/golden/expected_osv_parse/<зеркальный путь>.json` Для каждого файла `fixtures/osv/**` SHALL существовать снимок `fixtures/golden/expected_osv_parse/<зеркальный путь>.json`
(= файл результата без `job_id`, `file_id`, `service.version`, `metrics.duration_ms`); тест сравнивает разбор со снимком, (= файл результата без `job_id`, `file_id`, `service.version`, `metrics.duration_ms`); тест сравнивает разбор со снимком,

View File

@ -2,11 +2,35 @@
## MODIFIED Requirements ## 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:<job_id>:<attempt>`. При неудаче публикации
сервис 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: Парсер — заменяемый интерфейс ### Requirement: Парсер — заменяемый интерфейс
Разбор файла SHALL выполняться через единый интерфейс «байты + тип файла → документы/метрики/ошибка», не знающий о Разбор файла SHALL выполняться через единый интерфейс «байты + тип файла → документы/метрики/ошибка», не знающий о
NATS и S3; воркер MUST NOT зависеть от конкретной реализации. Реализация по умолчанию (`get_parser()`) SHALL разбирать NATS и S3; воркер MUST NOT зависеть от конкретной реализации. Реализация по умолчанию (`get_parser()`) SHALL разбирать
ОСВ по спеке `osv-parsing` и писать файл результата с `schema_version = "1.1"`; листы с признаками ББ/ОФР SHALL ОСВ по спеке `osv-parsing` и писать файл результата с `schema_version = "1.1"`; листы с признаками ББ/ОФР SHALL
пропускаться с предупреждением до подсреза 2b. Каталог `api/rules/` SHALL содержать словари ОСВ (`osv_headers`, пропускаться с предупреждением до подсреза 2b. Заглушка `NullParser` (`error NOT_IMPLEMENTED`) SHALL использоваться только
в тестах воркера и MUST NOT быть реализацией по умолчанию. Реализация по умолчанию SHALL создаваться один раз при старте
приложения (словари читаются и проверяются там же). Каталог `api/rules/` SHALL содержать словари ОСВ (`osv_headers`,
`osv_groups`, `periods`, `data_types`) и README. `osv_groups`, `periods`, `data_types`) и README.
#### Scenario: Подмена реализации в тесте #### Scenario: Подмена реализации в тесте

View File

@ -7,17 +7,19 @@
## 1. Основание ## 1. Основание
- [x] 1.1 Зависимости и фикстуры: `openpyxl==3.1.5`, `xlrd==2.0.2`, `pyxlsb==1.0.10` в `api/requirements.txt`; `make venv`; - [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_files` (все файлы `tests/fixtures_helpers.py` (`ARM_KA_FIXTURES`, как в arm-core) и `conftest.py` с фикстурами `osv_file` (все файлы
`../fixtures/osv/**` кроме `synthetic`, параметризация по относительному пути); маркер `fixtures` в деле; `test_layout` `../fixtures/osv/**` кроме `synthetic`) и `synthetic_file` (`../fixtures/osv/synthetic/*`), параметризация по
относительному пути; маркер `fixtures` в деле; `test_layout`
не ломается. Проверка: `pytest -m fixtures --collect-only` показывает 66 параметров. не ломается. Проверка: `pytest -m fixtures --collect-only` показывает 66 параметров.
- [x] 1.2 Контракт v1.1: скопировать `../docs/contracts/recognition-result.schema.json` v1.1 в `api/contracts/`, `OsvRow.extra: - [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 проходят; тест идентичности зелёный; воркер пишет dict[str, str | None] | None`, `CONTRACT_VERSION = "1.1"`; примеры docs проходят; тест идентичности зелёный; воркер пишет
`result_schema_version = "1.1"` (`test_worker` обновлён). Проверка: `make check`. `result_schema_version = "1.1"` (`test_worker` обновлён). Проверка: `make check`.
- [x] 1.3 `grid.py` и читатели: `Cell`, `Sheet`, `MergedRange`, `column_letter`; `readers/xlsx.py` (openpyxl, полное чтение, - [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), `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) -> tuple[Sheet, ...]` с `readers/xlsb.py` (pyxlsb, без outline), `readers/__init__.py:read_book(data, file_type) -> (sheets, warnings)` с
определением по сигнатуре и предупреждением при несоответствии; ошибки → `UnreadableError`/`UnsupportedFormatError`. определением по сигнатуре и предупреждением при несоответствии; ошибки → `UnreadableError`/`UnsupportedFormatError`.
Тесты: три читателя на одном синтетическом листе дают одинаковые ячейки; реальные xls/xlsx читаются с outline; Тесты: по читателю на собранной книге, реальные xls/xlsx читаются с outline и merged, синтетический xlsb (писателей
xls/xlsb для проверки «три читателя дают одинаковые ячейки» нет — решение R-11, design №21);
`fixtures`-тест: все 66 файлов читаются < 5 с каждый. `fixtures`-тест: все 66 файлов читаются < 5 с каждый.
- [x] 1.4 Словари: `api/rules/{osv_headers,osv_groups,periods,data_types}.yaml` (содержимое по §1.1/§1.5 и синонимам из §1.0), - [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, компиляция регулярок с нормализацией ё/е и пробелов), `src/parser/rules.py:load_rules(path) -> Rules` (pydantic, компиляция регулярок с нормализацией ё/е и пробелов),
@ -25,8 +27,9 @@
## 2. Обнаружение структуры ## 2. Обнаружение структуры
- [x] 2.1 `detect.py`: `find_osv_sheets`, `find_table_header` (группы по merged-тексту в 1–4 строках, Дебет/Кредит под - [x] 2.1 `detect.py`: `classify_sheet` (вид листа: ОСВ / форма ББ-ОФР / прочее), `find_table_header` (группы по
группой, свёрнутое сальдо, отсутствие «начала», `right_edge`, `text_col`, `inn_col` по заголовку и по доле 10/12 цифр, merged-тексту в 1–4 строках, Дебет/Кредит под группой, обязательны «обороты» и «конец» — R-12, свёрнутое сальдо,
отсутствие «начала», `right_edge`, `text_col`, `inn_col` по заголовку и по доле 10/12 цифр,
`extra`-колонки). Unit-тесты на сетках: заголовок 1/2/3/4 строки, merged, без «начала», свёрнутое сальдо, КПП между `extra`-колонки). Unit-тесты на сетках: заголовок 1/2/3/4 строки, merged, без «начала», свёрнутое сальдо, КПП между
ИНН и суммами, мусор правее. `fixtures`-тест: `columns` для РБ `E/H/J/L/O/Q`, для Счастливочной xls `G/J/K/N/Q/S` + `inn=E`. ИНН и суммами, мусор правее. `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`, - [x] 2.2 `header.py`: шапка документа (организация, счёт, период, data_type, валюта), `group_schema` вариант 1 и 2, `has_inn`,
@ -35,7 +38,8 @@
`account_code_raw` совпадает с кодом в имени каталога/файла по регулярке и период распознан. `account_code_raw` совпадает с кодом в имени каталога/файла по регулярке и период распознан.
- [x] 2.3 `hierarchy.py`: стратегии `outline`, `indent`, `account_pattern`, `sums`, каскад с проверкой непротиворечивости, - [x] 2.3 `hierarchy.py`: стратегии `outline`, `indent`, `account_pattern`, `sums`, каскад с проверкой непротиворечивости,
`parent_row_number`. Unit-тесты на сетках для каждой стратегии и для отказа стратегии (одинаковые уровни); `fixtures`-тест: `parent_row_number`. Unit-тесты на сетках для каждой стратегии и для отказа стратегии (одинаковые уровни); `fixtures`-тест:
`hierarchy=outline` у всех 66. `hierarchy=outline` у всех файлов со строками данных, `trivial` у пустых ОСВ (R-13: меньше двух строк данных; в
фикстурах таких две — schastl 58 и 66 за 2 кв. 2025).
- [x] 2.4 `classify.py` + `numbers.py`: классификация (ACCOUNT по регулярке, позиция в `group_schema`, OTHER при выходе, - [x] 2.4 `classify.py` + `numbers.py`: классификация (ACCOUNT по регулярке, позиция в `group_schema`, OTHER при выходе,
TOTAL, COUNTERPARTY с ИНН, Вал./Кол./код валюты → `data_type`), числа (Decimal, текстовые форматы, скобки, прочерки, TOTAL, COUNTERPARTY с ИНН, Вал./Кол./код валюты → `data_type`), числа (Decimal, текстовые форматы, скобки, прочерки,
экспонента), `extra`. Unit-тесты по §1.3 и §1.2; `fixtures`-тест: в `schastl …62 за 3 кв 2024` «БП Аренда» → DEPARTMENT, экспонента), `extra`. Unit-тесты по §1.3 и §1.2; `fixtures`-тест: в `schastl …62 за 3 кв 2024` «БП Аренда» → DEPARTMENT,
@ -56,18 +60,19 @@
- [x] 4.1 Генератор синтетики `tests/synthetic/generate.py` (детерминированный openpyxl) → `../fixtures/osv/synthetic/`: вариант 2 - [x] 4.1 Генератор синтетики `tests/synthetic/generate.py` (детерминированный openpyxl) → `../fixtures/osv/synthetic/`: вариант 2
шапки; без outline (отступы); без outline и отступов; Вал./Кол.; текстовые числа и скобки; два листа ОСВ + посторонний; шапки; без outline (отступы); без outline и отступов; Вал./Кол.; текстовые числа и скобки; два листа ОСВ + посторонний;
без «начала» + свёрнутое сальдо; КПП-колонка; название субсчёта в строке; испорченная сумма; плюс один xlsb, сконвертированный без «начала» + свёрнутое сальдо; КПП-колонка; название субсчёта в строке; испорченная сумма; плюс xlsb (s13), собранный тестовым писателем BIFF12 `tests/synthetic/xlsb_writer.py` (LibreOffice нет, R-9): не из
вручную из синтетического xlsx и закоммиченный с пометкой в README fixtures. Тесты сценариев спеки на этих файлах. Excel, совместимость с настоящим xlsb не доказана (O-7), пометка и инструкция замены — в README fixtures; s14–s16 —
записи словарей, которых нет в реальных файлах (R-22). Тесты сценариев спеки на этих файлах.
- [x] 4.2 Golden: `tests/golden_helpers.py` (нормализация результата без volatile-полей), тест сравнения для всех - [x] 4.2 Golden: `tests/golden_helpers.py` (нормализация результата без volatile-полей), тест сравнения для всех
`fixtures/osv/**` включая синтетику, `make golden-update` (перезапись + diff), первичная запись снимков в `fixtures/osv/**` включая синтетику, `make golden-update` (перезапись + diff), первичная запись снимков в
`../fixtures/golden/expected_osv_parse/` после ручной сверки «Итого» трёх файлов (РБ 60 1 кв 2025, Счастливочная 62 3 кв `../fixtures/golden/expected_osv_parse/` после ручной сверки «Итого» трёх файлов (РБ 60 1 кв 2025, Счастливочная 62 3 кв
2024 xls, 58.03) — сверка описывается в отчёте задачи. 2024 xls, 58.03) — сверка описывается в отчёте задачи.
- [x] 4.3 Линтер координат `tests/unit/test_no_coordinates.py` (AST по `src/parser/**` кроме `grid.py`, `readers/`) и тест - [x] 4.3 Линтер координат `tests/unit/parser/test_no_coordinates.py` (AST по `src/parser/**` кроме `grid.py`, `readers/`) и тест
покрытия словарей (каждая запись сработала хотя бы раз на фикстурах+синтетике; список несработавших — в сообщении). покрытия словарей (каждая запись сработала хотя бы раз на фикстурах+синтетике; список несработавших — в сообщении).
## 5. Завершение ## 5. Завершение
- [ ] 5.1 Документы: README (архитектура парсера, статусы и коды, словари, golden-процесс, лимиты), CLAUDE.md (модули, правила - [x] 5.1 Документы: README (архитектура парсера, статусы и коды, словари, golden-процесс, лимиты), CLAUDE.md (модули, правила
расширения), `rules/README.md`; `make check-full` (lint, unit, fixtures, integration, docker) зелёный, вывод — в PR. расширения), `rules/README.md`; `make check-full` (lint, unit, fixtures, integration, docker) зелёный, вывод — в PR.
Проверка: сценарии всех требований `osv-parsing` имеют тест (таблица в отчёте задачи). Проверка: сценарии всех требований `osv-parsing` имеют тест (таблица в отчёте задачи).
- [ ] 5.2 После merge: `openspec archive slice-2a-osv-parser`. - [ ] 5.2 После merge: `openspec archive slice-2a-osv-parser`.