From 7738221b5a33806362afb2d73be08e7edd536390 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 15:53:11 +0400 Subject: [PATCH] =?UTF-8?q?docs:=20README/CLAUDE.md=20=D0=A1=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=D0=B0=202a,=20tasks.md,=20=D1=81=D0=BF=D0=B5=D0=BA=D0=B0?= =?UTF-8?q?-=D0=B4=D0=B5=D0=BB=D1=8C=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- CLAUDE.md | 46 +++++- README.md | 134 ++++++++++++++++-- .../examples/result-osv-needs-check.json | 46 ++++-- api/rules/README.md | 23 ++- api/tests/fixtures/test_golden.py | 24 +--- api/tests/fixtures/test_osv_property.py | 1 + api/tests/golden_helpers.py | 26 ++++ api/tests/unit/test_golden_sync.py | 27 +++- .../plans/2026-10-05-slice-2a-osv-parser.md | 8 +- .../changes/slice-2a-osv-parser/design.md | 68 +++++---- .../changes/slice-2a-osv-parser/proposal.md | 5 +- .../specs/osv-parsing/spec.md | 104 ++++++++++---- .../specs/tabular-recognition-service/spec.md | 26 +++- openspec/changes/slice-2a-osv-parser/tasks.md | 27 ++-- 14 files changed, 431 insertions(+), 134 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4dbe354..a4c256e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,14 +38,54 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote `raisa_fastapi_protected_api`; шаблонный `README.md` заменён описанием сервиса. **Добавлено** (в скобках — задача `openspec/changes/slice-0-skeleton/tasks.md`): -- `api/src/parser/` — интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 и форм §12 — чистые функции над - книгой Excel, Срезы 2+; +- `api/src/parser/` — интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 — Срез 2a (см. ниже), форм §12 — + Срез 2b; - `api/src/contract/` — pydantic-модели контракта v1.1 (запрос, результат, конверт); `api/contracts/` — копии схем (идентичность с `../docs/contracts` проверяется в `make check`) — 3.1; - `api/rules/` — README о назначении; сами словари `*.yaml` (в документах `rules/*.yaml` относительно `api/`) - появятся в Срезе 2 — 4.1; + появились в Срезе 2a — 4.1; - `api/src/core/nats.py` и `api/src/core/worker.py` — подписка queue group `tabular-parser` на `recognition.tabular.request` → ack → S3 `uploads/` → парсер → S3 `results/` → `recognition.result` — 4.2–4.3; - `api/pyproject.toml` (ruff, mypy) и `Makefile` — 2.1; `Dockerfile`, `api/.env.example`, `api/.env.test` — 2.2. +## Добавлено в Срезе 2a (парсер ОСВ; `openspec/changes/slice-2a-osv-parser/`) + +**Модули `api/src/parser/`** (конвейер сверху вниз; подробности и статусы — `README.md`, раздел «Парсер ОСВ»): +- `grid.py` + `readers/` — модель листа и чтение xlsx/xls/xlsb; единственное место, где допустимы индексы и буквы колонок + (`column_letter` — только диагностика). `read_book(data, file_type) -> (листы, предупреждения)`, формат — по сигнатуре. +- `rules.py` — загрузчик словарей `api/rules/*.yaml`; `detect.py` — вид листа и заголовок таблицы; `header.py` — шапка + и `group_schema`; `hierarchy.py` — каскад `outline → indent → account_pattern → sums` (+ `trivial`); `numbers.py` — + `Decimal` и ИНН; `classify.py` — строки и группировки; `checks.py` — контрольные суммы §1.4; `osv.py` — + оркестратор `OsvParser`; `deadline.py` — кооперативный таймаут; `errors.py` — коды ошибок; `cli.py` — разбор файла. +- `get_parser(settings)` создаёт `OsvParser` один раз при старте (кэша нет); `NullParser` — только для тестов воркера. + +**Правила расширения:** +- Новый формат ОСВ = фикстура в `../fixtures/osv/` (или синтетика `s1x_…` в `tests/synthetic/generate.py`) + синоним в + YAML. Не новый `if` под файл, организацию, имя листа или номер строки; «нужна координата» означает, что алгоритм не по + содержимому — остановиться и обсудить. +- Нет координат: AST-линтер `tests/unit/parser/test_no_coordinates.py` проверяет `src/parser/**` (кроме `grid.py` и + `readers/`): литералы букв колонок и адресов ячеек, имена листов по умолчанию, сравнения номера строки с числом, + обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают. +- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле + (`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой. +- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 82 файла: 66 реальных + 16 синтетических) — регрессия, а не + оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность + проверяют property-тест и ручная сверка «Итого». +- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не + подгонять. + +**Нетривиальное правило §1.4 (R-21):** при сверке «сумма детей = родитель» обороты сравниваются по колонкам, а сальдо — +по колонкам либо по нетто `дебет − кредит`, если у родителя сальдо свёрнуто (1С показывает счёт нетто, а субсчета и +контрагентов — развёрнуто). Это решение зафиксировано в design №31; не ослаблять и не расширять без обсуждения. + +**Настройки:** `PARSE_MAX_BYTES` (по умолчанию 50 МиБ; больше — `UNSUPPORTED_FORMAT`) и `PARSE_TIMEOUT_SECONDS` (120 с; +кооперативный, → `TIMEOUT`) — `core/config.py`. + +**Контракт v1.1:** `rows[].extra` (нераспознанные колонки таблицы по заголовку); `CONTRACT_VERSION = "1.1"`; копия схемы +в `api/contracts/` идентична `../docs/contracts/` (проверяется в `make check`). + +**Тесты:** `make check` ≈ 95 с, потому что `make test` (`-m "not integration"`) включает тесты маркера `fixtures` (golden, +property, покрытие словарей); без каталога `../fixtures` они пропускаются. Полный набор — `make check-full` (+ интеграция +с compose и сборка образа). + Проверки: `make check` (`pytest`, `ruff check . && ruff format --check .`, `mypy src`), `make check-full` перед PR. diff --git a/README.md b/README.md index 0b1d98a..934e21d 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,14 @@ Stateless-сервис распознавания табличных файло Обмен с `arm-core` идёт по контракту **v1.1**: схемы — [`../docs/contracts/`](../docs/contracts/), копия — в `api/contracts/` (идентичность с источником проверяется в `make check`). -**Срез 0 — скелет.** Транспорт работает полностью (подписка, ack, чтение S3, публикация результата, ошибки транспорта), -а сам разбор заменён заглушкой: на любой корректный запрос сервис отвечает конвертом `status = error` с -`error.code = NOT_IMPLEMENTED`. Настоящий парсер (ОСВ — Срез 2, формы ББ/ОФР — позже) подключается через интерфейс -`Parser` без изменений воркера. +**Срез 0 — скелет.** Транспорт работает полностью (подписка, ack, чтение S3, публикация результата, ошибки транспорта); +разбор подключается через интерфейс `Parser` без изменений воркера. + +**Срез 2a — парсер ОСВ.** По умолчанию (`get_parser()`) разбор делает `OsvParser`: оборотно-сальдовые ведомости по +счетам из XLS/XLSX/XLSB, структура определяется по содержимому, достоверность подтверждается контрольными суммами +(§1 [`05-domain-rules.md`](../docs/context/05-domain-rules.md)). Листы с формами ББ/ОФР пропускаются с предупреждением +(подсрез 2b). Заглушка `NullParser` (`error = NOT_IMPLEMENTED`) осталась только для тестов воркера. Устройство парсера — +в разделе «Парсер ОСВ» ниже. Репозиторий — часть workspace АРМ КА. Общий контекст, модель, правила предметной области и контракты — в соседних репозиториях: @@ -40,8 +44,9 @@ Stateless-сервис распознавания табличных файло при неудаче повторяется до `PUBLISH_RETRIES` раз (после первой попытки, с паузой 2 с). Ошибки транспорта процесс не роняют, а превращаются в конверт `status = error`: нечитаемый или несовместимый запрос — -`SCHEMA_MISMATCH` (ack при этом не публикуется), объект S3 не читается — `FILE_READ_ERROR`, исключение парсера — -`UNKNOWN_ERROR`, заглушка парсера в Срезе 0 — `NOT_IMPLEMENTED`. +`SCHEMA_MISMATCH` (ack при этом не публикуется), объект S3 не читается — `FILE_READ_ERROR`, исключение, вышедшее из +парсера, — `UNKNOWN_ERROR`. Ожидаемые сбои разбора (не тот формат, нет листа ОСВ, лимит размера или времени) парсер +возвращает сам исходом `status = error` с кодом контракта — см. «Статусы и коды» ниже. ## Состав репозитория @@ -51,16 +56,18 @@ api/ бэкенд (все команды разработки src/app.py FastAPI: lifespan (MinIO, NATS, воркер), GET /healthcheck, middleware, обработчики ошибок src/core/ config.py (настройки), clients.py (S3), nats.py (соединение NATS), worker.py (воркер распознавания), logger.py, exception_handlers.py - src/parser/ интерфейс Parser и заглушка NullParser (разбор — Срез 2) + src/parser/ парсер ОСВ (Срез 2a): интерфейс Parser, OsvParser, читатели, детекторы, иерархия, классификация, + контрольные суммы, CLI; заглушка NullParser — для тестов воркера (модули — ниже) src/contract/ pydantic-модели контракта v1.1 contracts/ копии JSON-схем контракта и примеры - rules/ словари синонимов *.yaml для парсера (пока только README; словари — Срез 2) - tests/ pytest: unit и integration (маркер `integration`) + rules/ словари синонимов *.yaml (osv_headers, osv_groups, periods, data_types) и README + scripts/ golden_update.py — обновление golden-снимков + tests/ pytest: unit, integration (маркер `integration`), fixtures (маркер `fixtures`), synthetic setup/ pip.conf и setup.sh (создание venv без uv) raisa_config.yml конфигурация платформы РАИСА Makefile команды разработки Dockerfile образ сервиса, python:3.11-slim -openspec/ требования и дельта текущего среза (openspec/changes/slice-0-skeleton/) +openspec/ требования и дельта текущего среза (openspec/changes/slice-2a-osv-parser/) ``` ## Предварительные требования @@ -81,16 +88,106 @@ openspec/ требования и дельта текущего ср |---|---| | `make venv` | создаёт `api/.venv` (Python 3.11) и ставит `requirements.txt` и `requirements-dev.txt` через `uv` | | `make lint` | `ruff check .`, `ruff format --check .`, `mypy src` | -| `make test` | `pytest -m "not integration"` — без Docker | +| `make test` | `pytest -m "not integration"` — без Docker; включает тесты маркера `fixtures` (golden, property, покрытие словарей), без каталога `../fixtures` они пропускаются | | `make test-integration` | `pytest -m integration` — против NATS и MinIO из compose | | `make test-fixtures` | `pytest -m fixtures` — тесты на реальных ОСВ и синтетике из `../fixtures`: golden-снимки, покрытие словарей, property-тест | | `make golden-update` | перезаписывает golden-снимки в `../fixtures/golden/expected_osv_parse/` и печатает unified diff изменившихся | | `make golden-check` | то же без записи; код возврата 1, если снимки расходятся с разбором | -| `make check` | `make lint` + `make test` (включая идентичность копии контракта с `../docs/contracts`) | -| `make check-full` | `make check` + `make test-integration` + `docker build`; запускать перед PR | +| `make check` | `make lint` + `make test` (включая идентичность копии контракта с `../docs/contracts`); с каталогом fixtures ≈ 95 с — из-за тестов на 82 файлах | +| `make check-full` | `make check` + `make test-integration` + `docker build`; запускать перед PR (нужен поднятый compose, `make up`) | | `make up` | `docker compose -f ../docs/dev/docker-compose.yml up -d --wait postgres nats minio` | -Интеграционные тесты (`make test-integration`, `make check-full`) поднимают сервис в процессе теста и вступают в queue group `tabular-parser` на реальной шине: на шине compose не должно работать другого экземпляра tabular-parser (и наоборот: на время теста in-process воркер ответит `NOT_IMPLEMENTED` на реальные запросы других производителей). +Интеграционные тесты (`make test-integration`, `make check-full`) поднимают сервис в процессе теста и вступают в queue group `tabular-parser` на реальной шине: на шине compose не должно работать другого экземпляра tabular-parser (и наоборот: на время теста in-process воркер ответит ошибкой разбора (`UNSUPPORTED_FORMAT` / `UNREADABLE`) на реальные запросы других производителей). + +## Парсер ОСВ + +Парсер — набор чистых функций над единой моделью листа; воркер знает только интерфейс `Parser.parse(data, file_type) -> +ParseOutcome`. Структура ОСВ ищется **по содержимому**: ни букв колонок, ни номеров строк, ни имён листов в коде нет +(это проверяет AST-линтер, см. ниже), синонимы лежат в `api/rules/*.yaml`. + +### Модули (`api/src/parser/`) + +Конвейер идёт сверху вниз; все модули, кроме `grid.py` и `readers/`, оперируют индексами из содержимого и не знают +координат. + +| Модуль | Что делает | +|---|---| +| `grid.py` | модель листа: `Cell`, `Sheet`, `MergedRange`, `column_letter` (буква колонки — только для диагностики) | +| `readers/` | чтение книги в `Sheet`: `read_book(data, file_type) -> (листы, предупреждения)`; формат определяется по сигнатуре байтов (xlsx — openpyxl, xls — xlrd с `formatting_info`, xlsb — pyxlsb, без outline level); несовпадение с `file_type` — предупреждение | +| `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-снимки @@ -110,6 +207,12 @@ git -C ../fixtures add golden && git -C ../fixtures commit # снимки — ``` Править снимки вручную нельзя; новый файл в `osv/` без снимка — падение «нет снимка: выполните make golden-update». +Первый `make golden-update` для нового файла печатает в stdout файл снимка целиком (как «добавленные» строки diff), +для изменившегося — только diff; сводка «файлов / без изменений / новых / изменено / лишних» идёт в stderr. Снимки, +которым не соответствует ни один файл, считаются лишними: `make golden-update` их удаляет, `make golden-check` +завершается кодом 1. +Каталог `fixtures/osv/synthetic/` — 16 синтетических файлов `s01`–`s16` (генератор — `tests/synthetic/generate.py`): +форматы, которых нет в реальных ОСВ (шапка варианта 2, «Вал.»/«Кол.», xlsb, без outline level, текстовые числа и т. д.). Дополнительно: `tests/fixtures/test_rules_coverage.py` требует, чтобы каждая запись словарей `api/rules/*.yaml` сработала хотя бы на одном файле (мёртвый синоним — удалить или добавить синтетику, см. `api/rules/README.md`), а `tests/unit/parser/test_no_coordinates.py` — AST-линтер «без координат» в `src/parser/**`. @@ -153,6 +256,7 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XL Тип файла по умолчанию определяется по расширению (`.xls`, `.xlsx`, `.xlsb`, регистр не важен); `--file-type` его переопределяет, а для неизвестного расширения обязателен. `ENV_FILE=.env.test` нужен, потому что импорт пакета `src` читает настройки сервиса (`OPENBAO__…`); для разбора они не используются, подойдёт и `api/.env` (см. выше). +Симптом без окружения — `ValidationError: OPENBAO … Field required` при запуске: задайте `ENV_FILE=.env.test`. В stdout печатается JSON файла результата контракта v1.1 — тот же, что воркер кладёт в S3 под `results/{file_id}/{job_id}/result.json` (`job_id` и `file_id` — нулевые заглушки). Формат вывода: UTF-8 без @@ -225,6 +329,8 @@ ENV_FILE=.env.test .venv/bin/python -m src.parser.cli <файл> [--file-type XL | `WORKER_CONCURRENCY` | `1` | сколько заданий обрабатывается одновременно | | `PUBLISH_RETRIES` | `3` | повторы публикации ack и результата в NATS после первой попытки (пауза 2 с; `0` — без повторов) | | `DONE_CACHE_SIZE` | `1000` | размер LRU обработанных `job_id` (дедупликация внутри процесса) | +| `PARSE_MAX_BYTES` | `52428800` (50 МиБ) | лимит размера файла; больше — `error UNSUPPORTED_FORMAT` | +| `PARSE_TIMEOUT_SECONDS` | `120` | кооперативный таймаут разбора; превышение — `error TIMEOUT` | | `SERVER__HOST` | `0.0.0.0` | адрес привязки HTTP-сервера | | `SERVER__PORT` | `8000` | порт HTTP-сервера | diff --git a/api/contracts/examples/result-osv-needs-check.json b/api/contracts/examples/result-osv-needs-check.json index 0ac352f..1d029c7 100644 --- a/api/contracts/examples/result-osv-needs-check.json +++ b/api/contracts/examples/result-osv-needs-check.json @@ -22,15 +22,17 @@ "header_variant": 1 }, "group_schema": [ + { "type": "ACCOUNT", "name": "Счет" }, { "type": "COUNTERPARTY", "name": "Контрагенты", "has_inn": true } ], "columns": { - "opening_debit": "C", - "opening_credit": "D", - "turnover_debit": "E", - "turnover_credit": "F", - "closing_debit": "G", - "closing_credit": "H" + "opening_debit": "D", + "opening_credit": "E", + "turnover_debit": "F", + "turnover_credit": "G", + "closing_debit": "H", + "closing_credit": "I", + "inn": "B" }, "rows": [ { @@ -39,7 +41,7 @@ "parent_row_number": null, "row_value": "60", "group_type": "ACCOUNT", - "group_name": null, + "group_name": "Счет", "account_code_raw": "60", "counterparty_name": null, "counterparty_inn": null, @@ -55,7 +57,7 @@ "row_number": 2, "level": 1, "parent_row_number": 1, - "row_value": "ООО Лютик, ИНН 7711111111", + "row_value": "ООО Лютик", "group_type": "COUNTERPARTY", "group_name": "Контрагенты", "account_code_raw": "60", @@ -67,7 +69,8 @@ "turnover_debit": "500.00", "turnover_credit": "200.50", "closing_debit": "0.00", - "closing_credit": "700.50" + "closing_credit": "700.50", + "extra": { "КПП": "352801001" } }, { "row_number": 3, @@ -77,16 +80,33 @@ "group_type": "OTHER", "group_name": null, "account_code_raw": "60", - "counterparty_name": "ООО Лютик", - "counterparty_inn": "7711111111", + "counterparty_name": null, + "counterparty_inn": null, "data_type": "CURRENCY", "opening_debit": "0.00", "opening_credit": "400.00", "turnover_debit": "100.00", "turnover_credit": "50.00", "closing_debit": "0.00", - "closing_credit": "400.00", - "extra": { "КПП": "352801001" } + "closing_credit": "400.00" + }, + { + "row_number": 4, + "level": 0, + "parent_row_number": null, + "row_value": "Итого", + "group_type": "TOTAL", + "group_name": null, + "account_code_raw": null, + "counterparty_name": null, + "counterparty_inn": null, + "data_type": "BU", + "opening_debit": "0.00", + "opening_credit": "1000.00", + "turnover_debit": "500.00", + "turnover_credit": "200.50", + "closing_debit": "0.00", + "closing_credit": "700.50" } ], "checks": { diff --git a/api/rules/README.md b/api/rules/README.md index c3dbbf8..83f5609 100644 --- a/api/rules/README.md +++ b/api/rules/README.md @@ -8,12 +8,12 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`. Путь указан относительно `api/` (рабочего каталога команд сервиса): `api/rules/`. Загрузчик — `src/parser/rules.py` (`load_rules()`), источник содержимого — `docs/context/05-domain-rules.md` §1.0–§1.5. -| Файл | Что описывает | -|---|---| -| `osv_headers.yaml` | маркер листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `extra` под своим заголовком); строка «Итого»; метки шапки (счёт, период, выводимые данные, валюта, параметры, субконто) | -| `osv_groups.yaml` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) | -| `periods.yaml` | текст периода -> `period_quarter` и `is_cumulative` (§1.5); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») | -| `data_types.yaml` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.»; коды валют ISO 4217; синонимы валюты (`руб.` -> `RUB`) | +| Файл | Ключи | Что описывает | +|---|---|---| +| `osv_headers.yaml` | `sheet_markers`, `statement_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `header_markers` (`account`, `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки | +| `osv_groups.yaml` | `mapping`, `default` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) | +| `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end` | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») | +| `data_types.yaml` | `header`, `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) | ## Как писать паттерны @@ -38,13 +38,22 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`. `groups`, `debit`, `credit`, `service_columns`, `total_row`, `header_markers`, `mapping`, правила и `year`/`end` периодов, `header`/`row`/`currency_aliases` типов данных) должна сработать хотя бы раз на реальных ОСВ или синтетике: это проверяет `tests/fixtures/test_rules_coverage.py` (счётчик совпадений `Pattern`, сообщение — файл, ключ и паттерн -несработавших записей). Перечень `currency_codes` — список значений ISO 4217, а не синонимы, в покрытие не входит. +несработавших записей). Перечень `currency_codes` — список значений ISO 4217, а не синонимы, в покрытие не входит (решение R-22). Несработавшая запись — либо мёртвый синоним (удалить из YAML вместе с тестом словаря), либо пробел в фикстурах (добавить файл синтетики `s1x_…` с описанием в README fixtures). Так удалены `service_columns.kpp|code|quantity|currency`: код читает только `inn`, остальные колонки идут в `extra`. Синтетика `s14`–`s16` закрывает остальные записи, которых нет в реальных ОСВ (формы ББ/ОФР по одному признаку, названия групп сумм «Входящее/Начальное/Конечное/Исходящее сальдо», шапка без организации, период «2024», «Детализация: …», группировки `OTHER`). +## Как добавить синоним + +1. Найдите словарь и ключ по таблице выше; добавьте строку-паттерн в нужный список (первое совпадение побеждает — + ставьте специфичное выше общего). Пример: заголовок «Нач. остаток» вместо «Сальдо на начало» — + `groups.opening` в `osv_headers.yaml`: `- 'нач\.\s*остаток'`. +2. Положите файл с этим форматом в `../fixtures/osv/` (реальный) или добавьте синтетику в `tests/synthetic/generate.py` + (с описанием в README fixtures); иначе тест покрытия сочтёт запись мёртвой. +3. `make golden-update`, просмотрите diff, закоммитьте снимки в репозитории fixtures. Код парсера не меняется. + ## Что намеренно не в штатном словаре Синоним «нач. остаток» / «начальный остаток» для группы `opening` не включён: он нужен, чтобы показать, что новый diff --git a/api/tests/fixtures/test_golden.py b/api/tests/fixtures/test_golden.py index 295b976..5ed3f9a 100644 --- a/api/tests/fixtures/test_golden.py +++ b/api/tests/fixtures/test_golden.py @@ -8,40 +8,18 @@ from pathlib import Path 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 pytestmark = pytest.mark.fixtures -MISSING = "нет снимка: выполните make golden-update ({path})" -MAX_DIFF_CHARS = 6000 - @pytest.fixture(scope="module") def parser() -> 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: check_snapshot(fixtures_dir, osv_file, parser) diff --git a/api/tests/fixtures/test_osv_property.py b/api/tests/fixtures/test_osv_property.py index 235bd92..dac1b3f 100644 --- a/api/tests/fixtures/test_osv_property.py +++ b/api/tests/fixtures/test_osv_property.py @@ -32,6 +32,7 @@ def test_real_osv_parses_done_with_invariants(osv_file: Path) -> None: assert outcome.status is ResultStatus.DONE, outcome.metrics (document,) = outcome.documents assert isinstance(document, OsvDocument) + assert document.locator == "sheet:Лист_1" # spec «Один лист — одна ОСВ»: имя листа берётся из файла, не ищется checks = document.checks assert checks is not None assert checks.total_row_matches_root is True diff --git a/api/tests/golden_helpers.py b/api/tests/golden_helpers.py index f12957c..80a891c 100644 --- a/api/tests/golden_helpers.py +++ b/api/tests/golden_helpers.py @@ -15,6 +15,8 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any +import pytest + from src.contract import ResultStatus, ServiceInfo from src.core.config import Settings from src.core.worker import build_result @@ -22,9 +24,11 @@ from src.parser import Parser from src.parser.cli import STUB_ID, _error_payload, _file_type __all__ = [ + "MISSING", "SNAPSHOT_DIR", "SyncReport", "VOLATILE", + "check_snapshot", "golden_path", "normalize_result", "render", @@ -39,6 +43,8 @@ OSV_DIR = "osv" JSON_INDENT = 2 # Поля, которые меняются от запуска к запуску: идентификаторы заглушек, версия сервиса, фактическая длительность VOLATILE: tuple[str | tuple[str, str], ...] = ("job_id", "file_id", ("service", "version"), ("metrics", "duration_ms")) +MISSING = "нет снимка: выполните make golden-update ({path})" +MAX_DIFF_CHARS = 6000 def normalize_result(result: dict[str, Any]) -> dict[str, Any]: @@ -145,3 +151,23 @@ def sync_snapshots(fixtures_root: Path, files: Iterable[Path], parser: Parser, * if write: stale.unlink() 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, + ) diff --git a/api/tests/unit/test_golden_sync.py b/api/tests/unit/test_golden_sync.py index a7aad6c..61b6df7 100644 --- a/api/tests/unit/test_golden_sync.py +++ b/api/tests/unit/test_golden_sync.py @@ -4,7 +4,7 @@ import sys from pathlib import Path 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 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() +def test_golden_test_fails_with_hint_when_snapshot_is_missing(root: Path) -> None: + """Spec «Новый файл без снимка»: golden-тест сообщает «нет снимка: выполните make golden-update».""" + file = files_of(root)[0] + + with pytest.raises(pytest.fail.Exception, match=r"нет снимка: выполните make golden-update"): + check_snapshot(root, file, get_parser()) + + +def test_golden_test_fails_with_diff_when_parse_result_changed(root: Path) -> None: + """Spec «Регрессия»: расхождение разбора со снимком — падение с diff по этому файлу.""" + parser = get_parser() + sync_snapshots(root, files_of(root), parser, write=True) + file = files_of(root)[0] + check_snapshot(root, file, parser) # свежий снимок совпадает с разбором + target = golden_path(root, file) + target.write_text(target.read_text(encoding="utf-8").replace('"level": 1', '"level": 7', 1), encoding="utf-8") + + with pytest.raises(pytest.fail.Exception, match=r"отличается от снимка") as failure: + check_snapshot(root, file, parser) + + diff_lines = str(failure.value).splitlines() + assert any(line.startswith("-") and '"level": 7' in line for line in diff_lines) + assert any(line.startswith("+") and '"level": 1' in line for line in diff_lines) + + def test_script_check_exit_codes( root: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: diff --git a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md index b9b758f..abafaf7 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md +++ b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md @@ -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 (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` → сценарий → тест» в отчёте задачи; каждый сценарий имеет тест (иначе добавить). -- [ ] **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 зелёные. -- [ ] **Step 4:** коммиты: здесь `docs: README/CLAUDE.md Среза 2a, tasks.md`; в docs `docs: §1 по факту парсера, 06-test-data, план срезов 2a–2d, OQ`. +- [x] **Step 1:** таблица «требование спеки `osv-parsing` → сценарий → тест» в отчёте задачи; каждый сценарий имеет тест (иначе добавить). +- [x] **Step 2:** документы по списку; `openspec validate --strict` в обоих репозиториях. +- [x] **Step 3:** `make check-full` (compose поднят: `make up`; MinIO бакет `arm-ka` есть — проверить `test_s3_integration`, OQ-80) → всё зелёное, вывод в ledger; `pytest -m fixtures` → 66 + синтетика + golden зелёные. +- [x] **Step 4:** коммиты: здесь `docs: README/CLAUDE.md Среза 2a, tasks.md`; в docs `docs: §1 по факту парсера, 06-test-data, план срезов 2a–2d, OQ`. --- diff --git a/openspec/changes/slice-2a-osv-parser/design.md b/openspec/changes/slice-2a-osv-parser/design.md index 13e1951..817ebfe 100644 --- a/openspec/changes/slice-2a-osv-parser/design.md +++ b/openspec/changes/slice-2a-osv-parser/design.md @@ -24,41 +24,50 @@ ## Decisions ### D-1. Единая модель листа `Sheet`, читатели — адаптеры -`grid.py`: `Cell(value: str | Decimal | datetime | None, kind: Literal["text","number","date","empty"], bold: bool, -indent: int)`, `Sheet(name, cells: dict[tuple[int,int], Cell], merged: tuple[MergedRange, ...], row_outline: dict[int,int], -n_rows, n_cols)` с методами `row(r) -> list[Cell]`, `text(r,c)`, `merged_origin(r,c)`, `nonempty_rows()`. Индексы -1-based, как в Excel, для диагностики (`row_number` контракта). Читатели (`readers/xlsx.py`, `xls.py`, `xlsb.py`) -возвращают `tuple[Sheet, ...]`; числа сразу в `Decimal` (`str(float)` → `Decimal` с квантованием до 2 знаков только -для сумм, на уровне `numbers.py`). *Альтернатива* — работать напрямую с объектами openpyxl/xlrd: три разных API в -детекторах, невозможно тестировать на синтетических сетках. *Цена ошибки:* модель расширяется полями, адаптеры тонкие. +`grid.py`: `Cell(value: str | Decimal | date | datetime | bool | None, kind: Literal["empty","text","number","date","bool"], +bold: bool, indent: int)`, `Sheet(name, cells: Mapping[(row, col), Cell], merged: tuple[MergedRange, ...], row_outline: +Mapping[int, int], n_rows, n_cols)`. Хранятся только непустые ячейки; ячейки из одних пробелов не хранятся в индексе строк. +Методы: `cell(r, c)`, `text_at(r, c)` (внутри merged — текст origin), `merged_origin(r, c)`, `merged_span(r, c)`, +`row_cells(r)` (непустые `(col, cell)` по возрастанию колонки), `is_row_empty(r)`, `outline(r)`. Индексы 1-based, как в +Excel, для диагностики (`row_number` контракта). Читатели (`readers/xlsx.py`, `xls.py`, `xlsb.py`) возвращают +`tuple[Sheet, ...]`, а `read_book(data, file_type) -> (sheets, warnings)` выбирает читателя по сигнатуре байтов; числа — +`Decimal(str(value))` без квантования (квантование до 2 знаков только для сумм, в `numbers.py`). +*Альтернатива* — работать напрямую с объектами openpyxl/xlrd: три разных API в детекторах, невозможно тестировать на +синтетических сетках. *Цена ошибки:* модель расширяется полями, адаптеры тонкие. ### D-2. Детекторы — чистые функции над `Sheet`, результат — индексы, а не буквы -`detect.find_osv_sheets(book, rules) -> list[Sheet]`; `detect.find_table_header(sheet, rules) -> TableHeader(rows: range, -groups: {opening, turnover, closing} -> ColumnSpan, debit_credit: ColumnMap(six indexes | None), text_col, inn_col | None, -right_edge)`. Поиск групп — по тексту ячеек в пределах 1–4 подряд идущих строк с учётом merged (значение берётся из -origin), «Дебет/Кредит» ищутся в следующей строке внутри span группы; при отсутствии «начала» соответствующие индексы -`None`; одна колонка в группе без Дебет/Кредит → свёрнутое сальдо (знак = сторона) с предупреждением. Всё правее -`right_edge` игнорируется (§1.2). В `columns` контракта пишутся буквы колонок **только как диагностика** (функция -`column_letter(index)` в `grid.py`), код парсера по буквам ничего не ищет. +`detect.classify_sheet(sheet, rules) -> SheetKind` (ОСВ / форма ББ-ОФР / прочее); `detect.find_table_header(sheet, rules) +-> TableHeader(first_row, last_row, columns: ColumnMap(шесть индексов; `None` у «начала», если его нет), text_col, +inn_col | None, right_edge, extra_cols, group_label_rows)`. Поиск групп — по тексту ячеек в пределах 1–4 подряд идущих +строк с учётом merged (значение берётся из origin), «Дебет/Кредит» ищутся в следующей строке внутри span группы. **Строка +групп обязана содержать «обороты» и «сальдо на конец»; отсутствовать может только «начало»** (R-12, design №22): +кандидат без них пропускается, при отсутствии подходящего — `INVALID_FORMAT` с названием недостающей группы. Одна колонка +в группе без Дебет/Кредит → свёрнутое сальдо (знак = сторона) с предупреждением. Всё правее `right_edge` игнорируется +(§1.2). В `columns` контракта пишутся буквы колонок **только как диагностика** (функция `column_letter(index)` в +`grid.py`), код парсера по буквам ничего не ищет. ### D-3. Каскад стратегий иерархии `hierarchy.levels(sheet, body_rows, header) -> Levels(by_row: dict[int,int], strategy: str)`. Порядок: `outline` (если ≥ 2 различных уровней среди строк данных) → `indent` (`Cell.indent` или ведущие пробелы, ≥ 2 различных) → `account_pattern` (строка-счёт ранга по числу сегментов кода, не-счёт = ранг последнего счёта + 1 + глубина по жирности) → `sums` (строка — родитель следующих, пока сумма детей не совпадёт по шести колонкам). Первая стратегия, давшая -непротиворечивое дерево (каждая строка имеет родителя с меньшим уровнем, «Итого» level 0), побеждает; имя — в -`metrics.warnings` как `hierarchy=` всегда (чтобы golden фиксировал стратегию). *Цена ошибки:* порядок +непротиворечивое дерево (каждая строка имеет родителя с меньшим уровнем), побеждает; имя — в `metrics.warnings` как +`hierarchy=` всегда (чтобы golden фиксировал стратегию). Строка «Итого» стратегиям **не передаётся**: она всегда +уровень 0 без родителя (R-14, design №24). Если строк данных (кроме «Итого») меньше двух — стратегия `trivial` (дерево из +корней; пустая ОСВ, R-13, design №23); `outline` у всех файлов со строками данных, `trivial` — у пустых. *Цена ошибки:* порядок стратегий меняется в одном списке. ### D-4. Классификация и `group_schema` `header.group_schema(sheet, table_header, rules)`: вариант 1 — строки первой колонки блока заголовка сверху вниз, первая всегда ACCOUNT; вариант 2 — «субконто: A, B» через запятую после первого ACCOUNT; ячейка `ИНН` в строке -группировки → `has_inn`. `classify.rows(...)`: счёт по `^\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` остаётся как в файле (контракт), нормализованный код — забота ядра; не-счёт → группа по позиции (количество не-счётных предков после последнего ACCOUNT); для COUNTERPARTY `counterparty_name = row_value`, -`counterparty_inn` из колонки ИНН (только цифры, 10/12, иначе `null`); строки «Вал.», «Кол.», трёхбуквенные коды валют → -`data_type` CURRENCY/QUANTITY; их суммы остаются в шести полях, `data_type` лишь помечает строку, и в инвариантах §1.4 -такие строки исключаются. Последняя строка level 0 с текстом из словаря «Итого» → `TOTAL`. +`counterparty_inn` — только из колонки ИНН (цифры из текстовой ячейки, результат при 10 или 12 цифрах, иначе `null`; ИНН +внутри текста строки не извлекается — R-15, R-17); строки «Вал.», «Кол.», трёхбуквенные коды валют → `group_type` OTHER, +`data_type` CURRENCY/QUANTITY, без позиции в схеме и без `counterparty_*` (`account_code_raw` наследуется от ветки, R-19); +их суммы остаются в шести полях, `data_type` лишь помечает строку, и в инвариантах §1.4 такие строки исключаются. Последняя строка level 0 с текстом из словаря «Итого» → `TOTAL`. ### D-5. Числа `numbers.to_money(cell) -> Decimal | None`: число → `Decimal(str(value)).quantize(0.01)`; текст → убрать пробелы (в т.ч. @@ -70,11 +79,14 @@ NBSP, узкие), запятую → точку, `(x)` → `-x`, прочерк `error INVALID_FORMAT` (с указанием шага); книга не открывается → `UNREADABLE`; расширение/сигнатура не поддерживаются → `UNSUPPORTED_FORMAT`; размер > `PARSE_MAX_BYTES` → `UNSUPPORTED_FORMAT` с текстом про лимит; дедлайн превышен → `TIMEOUT`; любое непредвиденное исключение → `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= skipped: statement (slice 2b)`. ### 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`: каждая запись словаря матчится хотя бы одной фикстурой или синтетикой (иначе запись мёртвая — либо удалить, либо добавить синтетику). @@ -94,8 +106,8 @@ Property-тест параметризован по всем файлам `../fi равен коду корневой строки; `period_quarter` распознан; хотя бы один документ. Golden: `result.json` без `job_id`, `file_id`, `service.version`, `metrics.duration_ms`; каталог зеркалит `fixtures/osv/`; обновление — `make golden-update` (перезапись + вывод diff), отдельный коммит в fixtures. Синтетика: `tests/synthetic/generate.py` детерминированно пишет -xlsx в `../fixtures/osv/synthetic/`; xlsb — один файл, сконвертированный вручную из синтетического xlsx (LibreOffice) -и закоммиченный. Линтер `test_no_coordinates.py`: AST `src/parser/**` кроме `grid.py` и `readers/` — запрещены строковые +xlsx в `../fixtures/osv/synthetic/`; xlsb — один файл `s13`, собранный тестовым писателем BIFF12 (`tests/synthetic/xlsb_writer.py`; LibreOffice нет, R-9), +а не сконвертированный в Excel: совместимость с настоящим xlsb не доказана (O-7). Линтер `test_no_coordinates.py`: AST `src/parser/**` кроме `grid.py` и `readers/` — запрещены строковые литералы `^[A-Z]{1,3}\d*$` длиной ≤ 4, литералы с «Лист», сравнения `row == ` / индексация `[]` по `Sheet`. ## Risks / Trade-offs @@ -115,8 +127,10 @@ xlsx в `../fixtures/osv/synthetic/`; xlsb — один файл, сконвер ## Open Questions Не блокируют: OQ-02 (нарастающие по данным — ядро), OQ-45 (`NU` в ядре), OQ-01 (хранение «Итого»), OQ-73 (дедуп по -attempt), OQ-81 (закрывается хелпером фикстур). Новый вопрос в docs: единый список кодов валют для строк `data_type = -CURRENCY` (словарь `data_types.yaml` — ISO 4217 буквенные коды, достаточно ли?). +attempt), OQ-81 (закрыт хелпером фикстур tabular-parser). Новые вопросы внесены в `07-open-questions.md` задачей 13 +(OQ-98…OQ-105: коды валют CURRENCY-строк, несколько ОСВ одного счёта в книге, ИНН внутри текста строки и «ИНН/КПП», +числа с «+»/«.5»/«5.», xlsb из Excel, буквенный первый сегмент счёта, «Валюта» в строке детализации, подтверждение +сверки по нетто). ## 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 | | 31 | 2026-10-05 | R-21: сверка «сумма детей = родитель» (§1.4): обороты — по колонкам; сальдо — по колонкам ЛИБО, если у родителя сальдо свёрнуто (не более одной ненулевой стороны), по нетто «дебет − кредит» | 1С показывает счёт нетто (62 = 16,1 млн Дт), а субсчета развёрнуто (62.01 = 40,2 млн Дт, 62.02 = 24,1 млн Кт): 32 из 66 реальных файлов не сходились по шести колонкам только из-за этого; все 62 родителя проверены — обороты и нетто сходятся; негативы закреплены тестами; текст §1.4 и спеки правится в задаче 13 | перестановка сторон при совпавшем нетто родителя не ловится этой проверкой (ловят тождество строки и «Итого = корень») | | 32 | 2026-10-05 | R-22: тест покрытия словарей считает записи распознавания (паттерны osv_headers/osv_groups/periods/data_types); перечень кодов валют ISO (`currency_codes`) исключён — это список значений, а не синонимы; несработавшие паттерны удаляются из YAML или покрываются новым синтетическим файлом s14_* | список ISO нельзя навязать фикстурам | мёртвые коды валют остаются в списке | +| 33 | 2026-10-05 | O-7: `s13.xlsb` собран тестовым писателем BIFF12, а не Excel; совместимость с настоящим xlsb не доказана, замена — по инструкции в README fixtures | R-9, нет LibreOffice/Excel на машине | чтение xlsb из Excel может отличаться от проверенного; проявится на первом реальном xlsb | +| 34 | 2026-10-05 | O-8 (T12.1): строка детализации варианта 2 с группировкой «Валюта» совпадает с меткой валюты шапки и даёт ложное предупреждение «валюта «» не распознана» (в снимке s16); `currency = null` в обоих случаях; дефект принят, не исправлен | малое влияние, правка требует различать область шапки и строку детализации | ложное предупреждение у ОСВ с группировкой «Валюта» | diff --git a/openspec/changes/slice-2a-osv-parser/proposal.md b/openspec/changes/slice-2a-osv-parser/proposal.md index 7e11171..e2572cd 100644 --- a/openspec/changes/slice-2a-osv-parser/proposal.md +++ b/openspec/changes/slice-2a-osv-parser/proposal.md @@ -39,8 +39,9 @@ иерархия, классификация, числа, контрольные суммы, статусы и диагностика, словари, лимиты. ### Modified Capabilities -- `tabular-recognition-service`: требование «Парсер — заменяемый интерфейс» — реализация по умолчанию разбирает ОСВ, - `NOT_IMPLEMENTED` остаётся только для ББ/ОФР-листов (§12, следующий подсрез); результат пишется с `schema_version 1.1`. +- `tabular-recognition-service`: требования «Парсер — заменяемый интерфейс» и «Результат по контракту» — реализация по + умолчанию разбирает ОСВ, листы ББ/ОФР пропускаются с предупреждением (§12, следующий подсрез), `NOT_IMPLEMENTED` + остаётся только у заглушки `NullParser` в тестах воркера; результат пишется с `schema_version 1.1`. ## Impact diff --git a/openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md b/openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md index 2c8a0eb..8014825 100644 --- a/openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md +++ b/openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md @@ -10,7 +10,8 @@ ### Requirement: Листы ОСВ находятся по содержимому Парсер SHALL считать листом ОСВ любой лист, где хотя бы одна ячейка матчит признак ОСВ из `rules/osv_headers.yaml` (без учёта регистра, «ё/е», переносов строк и дефисов). Листы без признаков ОСВ 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:<имя>`. Имя листа MUST NOT использоваться для поиска. @@ -29,13 +30,14 @@ ### Requirement: Заголовок таблицы и колонки сумм определяются по тексту Парсер SHALL найти блок заголовка таблицы: 1–4 подряд идущих строки, в которых (с учётом объединённых ячеек) встречаются группы «сальдо на начало», «обороты за период», «сальдо на конец» по синонимам словаря, и под каждой группой подзаголовки -«Дебет»/«Кредит» по синонимам. Допустимы: отсутствие группы «начало» (сальдо на начало `null`), одна колонка в группе без +«Дебет»/«Кредит» по синонимам. Группы «обороты» и «сальдо на конец» обязательны: строка, где их нет, заголовком не +считается (R-12). Допустимы: отсутствие группы «начало» (сальдо на начало `null`), одна колонка в группе без Дебет/Кредит (свёрнутое сальдо: положительное — дебет, отрицательное — кредит, предупреждение), дополнительные колонки («Количество», «Валюта», «ИНН», «КПП», «Код»). Колонка текста строки — первая непустая слева от сумм по строкам данных; колонка ИНН — по заголовку `ИНН` или колонка, где ≥ 80 % непустых значений — 10 или 12 цифр. Правая граница таблицы — последняя колонка «Кредит»; всё правее MUST NOT попадать в суммы. Колонки внутри таблицы, не сопоставленные с шестью -суммами и ИНН, SHALL попадать в `rows[].extra` по заголовку. Заголовок не найден или найдено меньше двух групп → `status = -error`, `error.code = INVALID_FORMAT` с названием ненайденной группы. В `columns` SHALL быть записана диагностика (буква +суммами и ИНН, SHALL попадать в `rows[].extra` по заголовку. Заголовок не найден или в найденной строке групп нет «оборотов» либо «сальдо +на конец» → `status = error`, `error.code = INVALID_FORMAT` с названием ненайденной группы, листом и шагом. В `columns` SHALL быть записана диагностика (буква колонки) для каждой из шести сумм и ИНН. #### Scenario: Плавающие колонки двух организаций @@ -85,31 +87,44 @@ error`, `error.code = INVALID_FORMAT` с названием ненайденно ### Requirement: Иерархия строк — каскад стратегий Парсер SHALL определять `level` строк данных первой подходящей стратегией из списка: outline level Excel; отступ ячейки или ведущие пробелы; шаблон кода счёта и жирность; равенство «сумма детей = родитель». Стратегия подходит, если даёт дерево, -где у каждой строки есть предок с меньшим уровнем (кроме уровня 0) и строка «Итого» имеет уровень 0. Выбранная стратегия -SHALL быть записана в `metrics.warnings` как `hierarchy=<имя>`. `parent_row_number` SHALL указывать на ближайшую +где у каждой строки есть предок с меньшим уровнем (кроме уровня 0). Строка «Итого» стратегиям MUST NOT передаваться: +она всегда имеет уровень 0 и не имеет родителя (R-14). Если строк данных (кроме «Итого») меньше двух (пустая ОСВ или одна +строка) — стратегия `trivial`: дерево из корней (R-13). Выбранная стратегия SHALL быть записана в `metrics.warnings` как +`sheet=<имя> hierarchy=<имя стратегии>`. `parent_row_number` SHALL указывать на ближайшую предыдущую строку с меньшим уровнем. #### Scenario: Outline level (реальные файлы) -- **WHEN** разбирается `rb/2024-FY/ОСВ_66 за 2024.xls` +- **WHEN** разбирается `rb/2024-FY/ОСВ_66 за 2024.xls` (и любой другой реальный файл со строками данных) - **THEN** `hierarchy=outline`, строки `66` (0) → `66.01` (1) → контрагент (2); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» `parent_row_number` — строка `66.03` #### Scenario: Без outline level -- **WHEN** разбирается синтетическая копия той же ОСВ без группировок Excel, с отступами ячеек -- **THEN** `hierarchy=indent`, дерево и суммы совпадают с оригиналом +- **WHEN** разбирается синтетическая ОСВ без группировок Excel, с отступами ячеек (`s02`) +- **THEN** `hierarchy=indent`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по + кодам счетов и жирности (`s03`) #### Scenario: Без outline и отступов -- **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности -- **THEN** `hierarchy=account_pattern`, инварианты §1.4 выполнены +- **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`) +- **THEN** `hierarchy=account_pattern`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт + +#### Scenario: Пустая ОСВ — тривиальное дерево +- **WHEN** в ОСВ нет строк данных кроме «Итого» +- **THEN** `hierarchy=trivial`, «Итого» — уровень 0 без родителя ### Requirement: Классификация строк -Каждая строка данных SHALL получить `group_type`: ACCOUNT — текст матчит `^\d{2,3}(\.\d{1,2}){0,2}(,\s.*)?$` -(`account_code_raw` — код как в файле, без названия после запятой); не-счёт — группировка из `group_schema` по позиции -(число не-счётных предков после последнего ACCOUNT + 1), при выходе за схему — OTHER; последняя строка уровня 0 с текстом -из словаря «Итого» — TOTAL. Для COUNTERPARTY `counterparty_name = row_value`, `counterparty_inn` — только цифры длиной 10 -или 12 из колонки ИНН, иначе `null`. Строки «Вал.», «Кол.», трёхбуквенного кода валюты SHALL получать `data_type` -CURRENCY/QUANTITY (суммы остаются в шести полях), остальные — `data_type` шапки. Все строки, включая TOTAL, SHALL -присутствовать в `rows` (`ASSUMPTION(OQ-01)`: хранить ли «Итого», решает ядро). +Каждая строка данных SHALL получить `group_type`: ACCOUNT — текст матчит `ACCOUNT_RE` (регистр учитывается, IGNORECASE +нет): две-три цифры и до двух сегментов субсчёта через точку, где сегмент — `\d{1,2}` **или 1–3 заглавные буквы** +кириллицы/латиницы («76.АВ», «76.ВА», «76.ОТ», R-16), далее опционально запятая и название: `^\d{2,3}(\.(\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2}(,\s*.*)?$` +(после запятой допустим любой пробел или его отсутствие, R-18); `account_code_raw` — код как в файле, без названия после +запятой. Не-счёт — группировка из `group_schema` по позиции (число не-счётных предков после последнего ACCOUNT + 1), при +выходе за схему — OTHER; последняя строка уровня 0 с текстом из словаря «Итого» — TOTAL. Для COUNTERPARTY +`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: Подразделение, контрагент с ИНН, физлицо - **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls` @@ -120,9 +135,14 @@ CURRENCY/QUANTITY (суммы остаются в шести полях), ост - **WHEN** в синтетической ОСВ строка «60.01, Расчеты с поставщиками» - **THEN** `group_type = ACCOUNT`, `account_code_raw = "60.01"`, `row_value` — исходный текст +#### Scenario: Буквенный субсчёт +- **WHEN** в ОСВ 76 строка «76.АВ» (реальные файлы РБ и Счастливочной) либо «60.АВ, …» (синтетика) +- **THEN** `group_type = ACCOUNT`, строка наследуется детьми как счёт ветки; «76.ав» (строчные) счётом не считается + #### Scenario: Строки Вал./Кол. - **WHEN** в синтетической ОСВ под контрагентом строки «USD» и «Кол.» -- **THEN** `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 Суммы SHALL приводиться к `Decimal` с двумя знаками: числовые ячейки — через строковое представление; текстовые — @@ -137,7 +157,13 @@ CURRENCY/QUANTITY (суммы остаются в шести полях), ост ### Requirement: Контрольные суммы определяют статус Для каждого документа парсер SHALL проверить: по каждой строке `opening_debit − opening_credit + turnover_debit − 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` всего результата и запись причины в `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` — `row_number` родителя, в `warnings` — текст с номерами строк +#### Scenario: Сальдо родителя свёрнуто нетто +- **WHEN** у родителя сальдо показано одной стороной (нетто), а у детей — обеими сторонами, и нетто детей равно нетто родителя +- **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто + или расхождение оборотов — нарушение + +#### Scenario: «Итого» не найдена +- **WHEN** в таблице нет строки с текстом из словаря «Итого» +- **THEN** `total_row_matches_root = false`, `status = needs_check`, в `warnings` — «строка «Итого» не найдена» + #### 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), - `checks.total_row_matches_root = true` (корня нет, суммы TOTAL пусты), предупреждение «нет строк данных» + `hierarchy=trivial`, `checks.total_row_matches_root = true` (корня нет, суммы TOTAL пусты), предупреждение + «нет строк данных» ### Requirement: Форматы, лимиты, время Парсер 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 с. #### Scenario: XLSB -- **WHEN** разбирается синтетический xlsb той же ОСВ -- **THEN** результат совпадает с xlsx-оригиналом во всём, кроме `hierarchy=indent` и `locator` +- **WHEN** разбирается синтетический xlsb той же ОСВ, что `s02` (`s13`, собран тестовым писателем BIFF12, а не Excel — O-7) +- **THEN** результат совпадает с xlsx-оригиналом во всём, кроме `locator` и предупреждения «xlsb: outline level + недоступен»; `hierarchy=indent` #### Scenario: Расширение не совпадает с содержимым - **WHEN** запрос говорит `XLSX`, а байты — BIFF8 @@ -176,21 +214,29 @@ balance_identity_violations, parent_sum_violations}`. Любое нарушен - **THEN** `error UNSUPPORTED_FORMAT`, книга не открывается ### Requirement: Словари — данные, не код -Признаки листов, синонимы групп колонок и подзаголовков, служебных колонок, маркеров шапки (`rules/osv_headers.yaml`), -группировок (`rules/osv_groups.yaml`), периодов (`rules/periods.yaml`), типов данных и кодов валют (`rules/data_types.yaml`) +Признаки листов, синонимы групп колонок и подзаголовков, колонки ИНН, строки «Итого», маркеров шапки (`rules/osv_headers.yaml`: +`sheet_markers`, `statement_markers`, `groups`, `debit`, `credit`, `service_columns`, `total_row`, `header_markers`), +группировок (`rules/osv_groups.yaml`: `mapping`, `default`), периодов (`rules/periods.yaml`: `rules`, `year`, `end`), +типов данных и кодов валют (`rules/data_types.yaml`: `header`, `row`, `currency_codes`, `currency_aliases`) SHALL загружаться из YAML и валидироваться pydantic-моделью при старте (ошибка словаря — падение старта с указанием файла и записи). В `src/parser/` (кроме `grid.py` и `readers/`) MUST NOT быть строковых литералов букв колонок, адресов ячеек, -имён листов и сравнений с номерами строк — проверяется тестом по AST. Каждая запись словаря SHALL срабатывать хотя бы на -одной фикстуре или синтетике (тест покрытия словарей). +имён листов и сравнений с номерами строк — проверяется тестом по AST. Каждая запись распознавания словаря (паттерн) SHALL +срабатывать хотя бы на одной фикстуре или синтетике (тест покрытия словарей, R-22); перечень кодов валют +`currency_codes` (ISO 4217) — список значений, а не синонимы, и в покрытие не входит. #### Scenario: Новый синоним без релиза кода - **WHEN** в `osv_headers.yaml` добавлен синоним «Нач. остаток» и дана синтетическая ОСВ с таким заголовком -- **THEN** разбор проходит без изменений в `src/` +- **THEN** разбор проходит без изменений в `src/`; без этого синонима заголовок «Нач. остаток» не распознаётся + (колонки начала уходят в `extra`, результат `needs_check`) #### Scenario: Литерал координаты - **WHEN** в `detect.py` появляется строка `"E9"` - **THEN** тест-линтер падает с указанием файла и строки +#### Scenario: Мёртвая запись словаря +- **WHEN** в словарь добавлена запись, не срабатывающая ни на одном реальном или синтетическом файле +- **THEN** тест покрытия падает с указанием файла, ключа и паттерна + ### Requirement: Золотые снимки и property-тесты Для каждого файла `fixtures/osv/**` SHALL существовать снимок `fixtures/golden/expected_osv_parse/<зеркальный путь>.json` (= файл результата без `job_id`, `file_id`, `service.version`, `metrics.duration_ms`); тест сравнивает разбор со снимком, diff --git a/openspec/changes/slice-2a-osv-parser/specs/tabular-recognition-service/spec.md b/openspec/changes/slice-2a-osv-parser/specs/tabular-recognition-service/spec.md index ac477a1..cb27daf 100644 --- a/openspec/changes/slice-2a-osv-parser/specs/tabular-recognition-service/spec.md +++ b/openspec/changes/slice-2a-osv-parser/specs/tabular-recognition-service/spec.md @@ -2,11 +2,35 @@ ## MODIFIED Requirements +### Requirement: Результат по контракту +После подтверждения сервис SHALL прочитать объект `s3.key` из бакета `s3.bucket`, вызвать парсер, записать файл результата +(по `recognition-result.schema.json`) в `results/{file_id}/{job_id}/result.json` и опубликовать конверт результата (по +`recognition-result-message.schema.json`) с заголовком `Nats-Msg-Id = result::`. При неудаче публикации +сервис SHALL повторить её до трёх раз с паузой, затем залогировать ошибку. + +#### Scenario: Запрос с заглушкой парсера (Срез 0) +- **WHEN** воркеру явно передана заглушка `NullParser` (только тесты воркера; реализация по умолчанию — реальный парсер), + запрос подтверждён и объект существует в `uploads/` +- **THEN** в `recognition.result` появляется конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `job_id`/`file_id` + из запроса, `metrics.duration_ms ≥ 0`; файл результата может отсутствовать + +#### Scenario: Запрос с реальным парсером +- **WHEN** запрос подтверждён и объект в `uploads/` существует, но не является книгой Excel (сигнатура не XLS/XLSX/XLSB) +- **THEN** в `recognition.result` появляется конверт `status = error`, `error.code = UNSUPPORTED_FORMAT` (повреждённая + книга — `UNREADABLE`), `job_id`/`file_id` из запроса, `metrics.duration_ms ≥ 0`; файл результата может отсутствовать; + корректная ОСВ в том же потоке даёт `done` (см. «Парсер — заменяемый интерфейс») + +#### Scenario: Повтор публикации +- **WHEN** первая публикация конверта результата завершается ошибкой соединения, вторая — успешно +- **THEN** конверт опубликован один раз с тем же `Nats-Msg-Id`, обработка не повторяется + ### Requirement: Парсер — заменяемый интерфейс Разбор файла SHALL выполняться через единый интерфейс «байты + тип файла → документы/метрики/ошибка», не знающий о NATS и S3; воркер MUST NOT зависеть от конкретной реализации. Реализация по умолчанию (`get_parser()`) SHALL разбирать ОСВ по спеке `osv-parsing` и писать файл результата с `schema_version = "1.1"`; листы с признаками ББ/ОФР SHALL -пропускаться с предупреждением до подсреза 2b. Каталог `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. #### Scenario: Подмена реализации в тесте diff --git a/openspec/changes/slice-2a-osv-parser/tasks.md b/openspec/changes/slice-2a-osv-parser/tasks.md index 902bcc5..fb83b1f 100644 --- a/openspec/changes/slice-2a-osv-parser/tasks.md +++ b/openspec/changes/slice-2a-osv-parser/tasks.md @@ -7,17 +7,19 @@ ## 1. Основание - [x] 1.1 Зависимости и фикстуры: `openpyxl==3.1.5`, `xlrd==2.0.2`, `pyxlsb==1.0.10` в `api/requirements.txt`; `make venv`; - `tests/fixtures_helpers.py` (`ARM_KA_FIXTURES`, как в arm-core) и `conftest.py` с фикстурой `osv_files` (все файлы - `../fixtures/osv/**` кроме `synthetic`, параметризация по относительному пути); маркер `fixtures` в деле; `test_layout` + `tests/fixtures_helpers.py` (`ARM_KA_FIXTURES`, как в arm-core) и `conftest.py` с фикстурами `osv_file` (все файлы + `../fixtures/osv/**` кроме `synthetic`) и `synthetic_file` (`../fixtures/osv/synthetic/*`), параметризация по + относительному пути; маркер `fixtures` в деле; `test_layout` не ломается. Проверка: `pytest -m fixtures --collect-only` показывает 66 параметров. - [x] 1.2 Контракт v1.1: скопировать `../docs/contracts/recognition-result.schema.json` v1.1 в `api/contracts/`, `OsvRow.extra: dict[str, str | None] | None`, `CONTRACT_VERSION = "1.1"`; примеры docs проходят; тест идентичности зелёный; воркер пишет `result_schema_version = "1.1"` (`test_worker` обновлён). Проверка: `make check`. - [x] 1.3 `grid.py` и читатели: `Cell`, `Sheet`, `MergedRange`, `column_letter`; `readers/xlsx.py` (openpyxl, полное чтение, `outline_level`, `alignment.indent`, bold), `readers/xls.py` (xlrd `formatting_info`, `rowinfo_map`, XF bold/indent), - `readers/xlsb.py` (pyxlsb, без outline), `readers/__init__.py:read_book(data, file_type) -> tuple[Sheet, ...]` с + `readers/xlsb.py` (pyxlsb, без outline), `readers/__init__.py:read_book(data, file_type) -> (sheets, warnings)` с определением по сигнатуре и предупреждением при несоответствии; ошибки → `UnreadableError`/`UnsupportedFormatError`. - Тесты: три читателя на одном синтетическом листе дают одинаковые ячейки; реальные xls/xlsx читаются с outline; + Тесты: по читателю на собранной книге, реальные xls/xlsx читаются с outline и merged, синтетический xlsb (писателей + xls/xlsb для проверки «три читателя дают одинаковые ячейки» нет — решение R-11, design №21); `fixtures`-тест: все 66 файлов читаются < 5 с каждый. - [x] 1.4 Словари: `api/rules/{osv_headers,osv_groups,periods,data_types}.yaml` (содержимое по §1.1/§1.5 и синонимам из §1.0), `src/parser/rules.py:load_rules(path) -> Rules` (pydantic, компиляция регулярок с нормализацией ё/е и пробелов), @@ -25,8 +27,9 @@ ## 2. Обнаружение структуры -- [x] 2.1 `detect.py`: `find_osv_sheets`, `find_table_header` (группы по merged-тексту в 1–4 строках, Дебет/Кредит под - группой, свёрнутое сальдо, отсутствие «начала», `right_edge`, `text_col`, `inn_col` по заголовку и по доле 10/12 цифр, +- [x] 2.1 `detect.py`: `classify_sheet` (вид листа: ОСВ / форма ББ-ОФР / прочее), `find_table_header` (группы по + merged-тексту в 1–4 строках, Дебет/Кредит под группой, обязательны «обороты» и «конец» — R-12, свёрнутое сальдо, + отсутствие «начала», `right_edge`, `text_col`, `inn_col` по заголовку и по доле 10/12 цифр, `extra`-колонки). Unit-тесты на сетках: заголовок 1/2/3/4 строки, merged, без «начала», свёрнутое сальдо, КПП между ИНН и суммами, мусор правее. `fixtures`-тест: `columns` для РБ `E/H/J/L/O/Q`, для Счастливочной xls `G/J/K/N/Q/S` + `inn=E`. - [x] 2.2 `header.py`: шапка документа (организация, счёт, период, data_type, валюта), `group_schema` вариант 1 и 2, `has_inn`, @@ -35,7 +38,8 @@ `account_code_raw` совпадает с кодом в имени каталога/файла по регулярке и период распознан. - [x] 2.3 `hierarchy.py`: стратегии `outline`, `indent`, `account_pattern`, `sums`, каскад с проверкой непротиворечивости, `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 при выходе, TOTAL, COUNTERPARTY с ИНН, Вал./Кол./код валюты → `data_type`), числа (Decimal, текстовые форматы, скобки, прочерки, экспонента), `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 шапки; без outline (отступы); без outline и отступов; Вал./Кол.; текстовые числа и скобки; два листа ОСВ + посторонний; - без «начала» + свёрнутое сальдо; КПП-колонка; название субсчёта в строке; испорченная сумма; плюс один xlsb, сконвертированный - вручную из синтетического xlsx и закоммиченный с пометкой в README fixtures. Тесты сценариев спеки на этих файлах. + без «начала» + свёрнутое сальдо; КПП-колонка; название субсчёта в строке; испорченная сумма; плюс xlsb (s13), собранный тестовым писателем BIFF12 `tests/synthetic/xlsb_writer.py` (LibreOffice нет, R-9): не из + Excel, совместимость с настоящим xlsb не доказана (O-7), пометка и инструкция замены — в README fixtures; s14–s16 — + записи словарей, которых нет в реальных файлах (R-22). Тесты сценариев спеки на этих файлах. - [x] 4.2 Golden: `tests/golden_helpers.py` (нормализация результата без volatile-полей), тест сравнения для всех `fixtures/osv/**` включая синтетику, `make golden-update` (перезапись + diff), первичная запись снимков в `../fixtures/golden/expected_osv_parse/` после ручной сверки «Итого» трёх файлов (РБ 60 1 кв 2025, Счастливочная 62 3 кв 2024 xls, 58.03) — сверка описывается в отчёте задачи. -- [x] 4.3 Линтер координат `tests/unit/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.1 Документы: README (архитектура парсера, статусы и коды, словари, golden-процесс, лимиты), CLAUDE.md (модули, правила +- [x] 5.1 Документы: README (архитектура парсера, статусы и коды, словари, golden-процесс, лимиты), CLAUDE.md (модули, правила расширения), `rules/README.md`; `make check-full` (lint, unit, fixtures, integration, docker) зелёный, вывод — в PR. Проверка: сценарии всех требований `osv-parsing` имеют тест (таблица в отчёте задачи). - [ ] 5.2 После merge: `openspec archive slice-2a-osv-parser`.