OpenSpec: change slice-2a-osv-parser (парсер ОСВ §1 — спека)

proposal, design (D-1…D-10), спеки osv-parsing (новая) и tabular-recognition-service (MODIFIED «Парсер — заменяемый
интерфейс»), tasks (13 задач). Решение владельца 05.10.2026: подсрез 2a — только парсер ОСВ.

Issue: RSHB/arm-tabular-parser#5

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 11:41:52 +04:00
parent 277e01d205
commit a6fd2d0535
6 changed files with 498 additions and 0 deletions

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-05

View File

@ -0,0 +1,134 @@
# Design: Срез 2a — парсер ОСВ
## Context
- Точка подключения — `api/src/parser/__init__.py:get_parser()`; воркер вызывает `parser.parse(data, file_type)` в
`asyncio.to_thread` и сам собирает конверт, пишет `results/{file_id}/{job_id}/result.json` (`worker.py`).
`ParseOutcome` требует непустые `documents` при `done|needs_check` и непустую `error` при `error`.
- Контракт v1.0: `osvDocument{locator, header, group_schema, columns, rows[], checks}`; у строки нет места для
нераспознанных колонок — §1.2 требует `extra`. В v1.1 (docs change) добавляется `rows[].extra: object`.
- Реальные файлы (66, `../fixtures/osv/**`): вариант 1 шапки, заголовок таблицы 2–3 строки с merged, колонки сумм
плавают (E/H…Q у РБ, G/J…S у Счастливочной), ИНН в отдельной колонке у одной организации, outline level есть и в xls, и в
xlsx (xls через `xlrd.rowinfo_map`), «Итого» последней строкой level 0, мусор правее таблицы (экспоненты, числа в T/U),
пустая ОСВ (66 за 2 кв. 2025), ОСВ по субсчёту 58.03, физлица/ИП как контрагенты. Нет: вариант 2 шапки, Вал./Кол.,
файлы без outline, несколько листов, xlsb, текстовые числа.
- Платформа: Python 3.11; `openpyxl` без `read_only` (иначе нет outline level), `xlrd` с `formatting_info=True`,
`pyxlsb` без outline level. Размеры до ~330 КБ и ~1000 строк; 2280 merged-диапазонов на файл.
## Goals / Non-Goals
**Goals:** все 66 реальных файлов и вся синтетика дают `done` с выполненными инвариантами §1.4 без правил под файл;
в `src/parser/` нет литералов координат (линтер); каждый шаг обнаружения тестируем отдельно; диагностика говорит,
какой шаг не уверен. **Non-Goals:** см. proposal.
## Decisions
### D-1. Единая модель листа `Sheet`, читатели — адаптеры
`grid.py`: `Cell(value: str | Decimal | 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 в
детекторах, невозможно тестировать на синтетических сетках. *Цена ошибки:* модель расширяется полями, адаптеры тонкие.
### 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`), код парсера по буквам ничего не ищет.
### 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=<strategy>` всегда (чтобы golden фиксировал стратегию). *Цена ошибки:* порядок
стратегий меняется в одном списке.
### 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 →
60.01`) в `account_code_raw` остаётся как в файле (контракт), нормализованный код — забота ядра; не-счёт → группа по
позиции (количество не-счётных предков после последнего ACCOUNT); для COUNTERPARTY `counterparty_name = row_value`,
`counterparty_inn` из колонки ИНН (только цифры, 10/12, иначе `null`); строки «Вал.», «Кол.», трёхбуквенные коды валют →
`data_type` CURRENCY/QUANTITY с наследованием сумм в `extra`? — нет: суммы остаются в шести полях, `data_type` помечает
строку, в инвариантах §1.4 такие строки исключаются. Последняя строка level 0 с текстом из словаря «Итого» → `TOTAL`.
### D-5. Числа
`numbers.to_money(cell) -> Decimal | None`: число → `Decimal(str(value)).quantize(0.01)`; текст → убрать пробелы (в т.ч.
NBSP, узкие), запятую → точку, `(x)` → `-x`, прочерки/пусто → `None`; нечисловой текст в колонке сумм → `None` + предупреждение
с `row_number`. Экспоненциальная запись принимается (`8.577e+00`), но такие ячейки вне `right_edge` в суммы не попадают.
### D-6. Статусы
`osv.parse`: нет листов ОСВ → `error INVALID_DOCUMENT`; лист ОСВ без заголовка таблицы или без колонок сумм →
`error INVALID_FORMAT` (с указанием шага); книга не открывается → `UNREADABLE`; расширение/сигнатура не поддерживаются →
`UNSUPPORTED_FORMAT`; размер > `PARSE_MAX_BYTES` → `UNSUPPORTED_FORMAT` с текстом про лимит; дедлайн превышен → `TIMEOUT`;
любое непредвиденное исключение → `RECOGNITION_FAILED`. Инварианты §1.4 нарушены хотя бы на одном листе → `needs_check`
всего результата, номера строк в `checks`, причины в `warnings`. Несколько листов ОСВ → несколько документов, лист с
признаками ББ/ОФР (§12 п. 1) → пропуск с предупреждением `sheet=<name> skipped: statement (slice 2b)`.
### D-7. Словари — pydantic-модели из YAML, загружаются один раз
`rules/loader.py: load_rules(path) -> Rules` (frozen), кэш в `get_parser()`. Регулярки компилируются с `re.IGNORECASE`
после нормализации «ё→е» и схлопывания пробелов/переносов обеих сторон. Тест `test_rules_coverage`: каждая запись
словаря матчится хотя бы одной фикстурой или синтетикой (иначе запись мёртвая — либо удалить, либо добавить синтетику).
### D-8. Лимиты и таймаут без убийства потока
`Deadline(monotonic + PARSE_TIMEOUT_SECONDS)` проверяется между листами, между стратегиями и каждые 500 строк; превышение →
`TIMEOUT`. Это не защищает от зависания внутри openpyxl при чтении — там работает лимит размера. Значения по умолчанию
50 МиБ / 120 с — ниже `reply_deadline` ядра (5 мин).
### D-9. Контракт 1.1 и копии
`CONTRACT_VERSION = "1.1"`; `OsvRow.extra: dict[str, str | None]` (ключ — заголовок колонки как в файле или буква, если
заголовка нет; значение — текст ячейки). Копия схемы в `api/contracts/` обновляется из `../docs/contracts/` в той же
задаче (тест идентичности). arm-core получает копию отдельным PR без логики (ядро пока результат не читает).
### D-10. Тесты: инварианты вместо оракула, golden как регрессия
Property-тест параметризован по всем файлам `../fixtures/osv/**` (`@pytest.mark.fixtures`, хелпер `fixtures_helpers.py` с
`ARM_KA_FIXTURES`): статус ≠ `error`; `checks.total_row_matches_root`; нет нарушений §1.4; `header.account_code_raw`
равен коду корневой строки; `period_quarter` распознан; хотя бы один документ. Golden: `result.json` без `job_id`,
`file_id`, `service.version`, `metrics.duration_ms`; каталог зеркалит `fixtures/osv/`; обновление — `make golden-update`
(перезапись + вывод diff), отдельный коммит в fixtures. Синтетика: `tests/synthetic/generate.py` детерминированно пишет
xlsx в `../fixtures/osv/synthetic/`; xlsb — один файл, сконвертированный вручную из синтетического xlsx (LibreOffice)
и закоммиченный. Линтер `test_no_coordinates.py`: AST `src/parser/**` кроме `grid.py` и `readers/` — запрещены строковые
литералы `^[A-Z]{1,3}\d*$` длиной ≤ 4, литералы с «Лист», сравнения `row == <int>` / индексация `[<int>]` по `Sheet`.
## Risks / Trade-offs
- [`openpyxl` без `read_only` на большом файле медленный/прожорливый] → лимит 50 МиБ; реальные файлы ≤ 330 КБ.
- [Каскад выберет не ту стратегию] → контрольные суммы §1.4 переводят в `needs_check`, стратегия видна в warnings.
- [Шапка варианта 2 и Вал./Кол. проверяются только синтетикой] → первая реальная ОСВ другого клиента добавляется как
фикстура; принцип «новый формат = фикстура + синоним».
- [xlsb без outline level] → для xlsb работает каскад с `indent`/`account_pattern`/`sums`; тест на синтетическом xlsb.
- [Контракт 1.1 ломает побайтовую идентичность копии в arm-core до его PR] → PR в arm-core — в тот же день, тест там
пропускается только при недоступном docs, поэтому порядок merge: docs → tabular-parser → arm-core.
## Migration Plan
Не требуется: сервис без состояния. После merge — `make venv` (новые зависимости), образ пересобирается.
## Open Questions
Не блокируют: OQ-02 (нарастающие по данным — ядро), OQ-45 (`NU` в ядре), OQ-01 (хранение «Итого»), OQ-73 (дедуп по
attempt), OQ-81 (закрывается хелпером фикстур). Новый вопрос в docs: единый список кодов валют для строк `data_type =
CURRENCY` (словарь `data_types.yaml` — ISO 4217 буквенные коды, достаточно ли?).
## Decisions made without discussion
| № | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| 1 | 2026-10-05 | D-1: единая модель `Sheet`, читатели-адаптеры | три библиотеки, один набор детекторов, тесты на синтетических сетках | расширить модель |
| 2 | 2026-10-05 | D-2: буквы колонок только в диагностике `columns`, детекторы работают с индексами | §1.0, DoD «нет литералов координат» | нет |
| 3 | 2026-10-05 | D-3: порядок стратегий outline → indent → account_pattern → sums; стратегия всегда в warnings | §1.0 п. 5; golden фиксирует стратегию | переставить список |
| 4 | 2026-10-05 | D-4: `account_code_raw` как в файле, нормализация кода — в ядре | контракт v1.0 так определяет поле | добавить поле в контракт |
| 5 | 2026-10-05 | D-6: карта статусов/кодов (INVALID_DOCUMENT без листов ОСВ, INVALID_FORMAT без заголовка, UNSUPPORTED_FORMAT при лимите размера) | enum контракта фиксирован | переназначить код |
| 6 | 2026-10-05 | D-8: таймаут кооперативный (между шагами), лимит 50 МиБ / 120 с | `to_thread` нельзя прервать (worker.py) | числа в Settings |
| 7 | 2026-10-05 | D-9: контракт MINOR 1.1 с `rows[].extra`; `CONTRACT_VERSION = "1.1"` | §1.2 «не терять» колонки; обратно совместимо | откатить до 1.0, потеря extra |
| 8 | 2026-10-05 | D-10: golden без volatile-полей, обновление `make golden-update`, xlsb вручную один раз | нет LibreOffice в проверке | генерировать xlsb иначе |
| 9 | 2026-10-05 | Лист с признаками ББ/ОФР пропускается с предупреждением, а не `NOT_IMPLEMENTED` на весь файл | файл с ОСВ и формой должен отдать ОСВ | вернуть ошибку |
| 10 | 2026-10-05 | ASSUMPTION(OQ-02/45/01): `is_cumulative` по тексту; `NU` как есть; «Итого» строкой TOTAL | парсер не видит других файлов и БД | правка ядра |

View File

@ -0,0 +1,63 @@
# Proposal: Срез 2a — парсер ОСВ из XLS/XLSX/XLSB (динамическое обнаружение структуры §1)
**Срез:** 2 «Загрузка файлов + табличный парсер» (`../docs/context/09-delivery-plan.md`), первый подсрез по решению
владельца 05.10.2026: **только парсер ОСВ**. ББ/ОФР из Excel (§12), загрузка файлов и маппинг результата в ядре, UI —
следующие подсрезы. **Репозиторий:** arm-tabular-parser. **Issue:** RSHB/arm-tabular-parser#5.
**Связанные change'и:** `../docs/openspec/changes/slice-2a-osv-parser` (контракт v1.1, §1, OQ; RSHB/arm-ka-docs#9),
фикстуры RSHB/arm-ka-fixtures#4 (синтетика, золотые снимки).
## Why
После Среза 0 сервис принимает задания и отвечает `NOT_IMPLEMENTED`. Без разбора ОСВ невозможны ни сводная ОСВ, ни
ВГО, ни КФО — это самый рискованный блок системы (§1.0: ОСВ приходят из разных конфигураций 1С и других систем, и
«ни одна буква колонки, ни один номер строки, ни одно имя листа не являются контрактом»). В fixtures есть 66 реальных
ОСВ двух организаций (xls и xlsx, 2 и 3 строки заголовка, плавающие колонки, иерархия до 4 уровней, мусор правее
таблицы, пустая ОСВ), на которых парсер обязан проходить без единого правила под файл.
## What Changes
- **Парсер ОСВ** `api/src/parser/`: единая модель листа (`grid.py`), читатели xlsx/xls/xlsb, детекторы структуры по
содержимому (`detect.py`), шапка и `group_schema` (`header.py`), каскад стратегий иерархии (`hierarchy.py`),
классификация строк (`classify.py`), нормализация чисел в `Decimal` (`numbers.py`), контрольные суммы §1.4
(`checks.py`), оркестратор (`osv.py`). `get_parser()` возвращает реальную реализацию; воркер, NATS и S3 без изменений.
- **Словари** `api/rules/{osv_headers,osv_groups,periods,data_types}.yaml` с pydantic-валидацией при старте и тестом
«каждая запись срабатывает хотя бы раз».
- **Зависимости** `api/requirements.txt`: `openpyxl==3.1.5`, `xlrd==2.0.2`, `pyxlsb==1.0.10`.
- **Настройки** `PARSE_MAX_BYTES` (50 МиБ), `PARSE_TIMEOUT_SECONDS` (120) — проверка дедлайна между шагами, без
убийства потока.
- **Контракт v1.1**: парсер пишет `schema_version = "1.1"` и заполняет `rows[].extra` (нераспознанные колонки, §1.2);
копия схемы в `api/contracts/` обновляется вместе с источником в `../docs/contracts/`.
- **Тесты**: unit по модулям на синтетических сетках; property-тест инвариантов §1.4 на всех `../fixtures/osv/**`;
золотые снимки `../fixtures/golden/expected_osv_parse/`; синтетические файлы `../fixtures/osv/synthetic/` из
детерминированного генератора; тест-линтер «нет координат в `src/parser/`»; хелпер `ARM_KA_FIXTURES` (OQ-81).
- **Документы**: README (как запустить на файле, коды ошибок, словари), CLAUDE.md, спека `osv-parsing`.
## Capabilities
### New Capabilities
- `osv-parsing`: разбор ОСВ из Excel по §1 — обнаружение листов, заголовка и колонок по содержимому, шапка,
иерархия, классификация, числа, контрольные суммы, статусы и диагностика, словари, лимиты.
### Modified Capabilities
- `tabular-recognition-service`: требование «Парсер — заменяемый интерфейс» — реализация по умолчанию разбирает ОСВ,
`NOT_IMPLEMENTED` остаётся только для ББ/ОФР-листов (§12, следующий подсрез); результат пишется с `schema_version 1.1`.
## Impact
- Код: `api/src/parser/**` (новое), `api/src/core/config.py` (две настройки), `api/src/contract/common.py`
(`CONTRACT_VERSION = "1.1"`), `api/contracts/recognition-result.schema.json` (копия v1.1), `api/rules/*.yaml`,
`api/requirements.txt`, тесты.
- Таблицы DBML: не затрагиваются (сервис без БД). Поля результата — только из контракта.
- Правила: §1.0–§1.5, §1.2 (`extra`), §1.4 (инварианты), §1.5 (период). Допущения: `ASSUMPTION(OQ-02)` — `is_cumulative`
только по тексту; `ASSUMPTION(OQ-45)` — `data_type = NU` выдаётся как есть; `ASSUMPTION(OQ-01)` — «Итого» выдаётся
строкой `TOTAL`, решение о хранении — за ядром.
- Соседи: `../docs` (контракт v1.1, §1), `../fixtures` (синтетика, golden), `arm-core` — копия схемы v1.1 отдельным
bounded-PR, логика ядра не меняется (`NoopMapper`).
## Non-goals
- ББ/ОФР из Excel (§12) — подсрез 2b; листы с признаками форм в этом подсрезе пропускаются с предупреждением.
- Загрузка файлов, `stored_file`/`recognition_job` в ядре, маппинг результата в `document`/`osv_entry`, UI — подсрезы 2c/2d.
- Проверка `is_cumulative` по данным других ОСВ (OQ-02), резолв контрагентов и счетов — ядро.
- Отмена разбора извне, JetStream, изменение дедупа `(job_id, attempt)` (OQ-73).
- Распознавание сканов и PDF.

View File

@ -0,0 +1,207 @@
# Spec Delta: osv-parsing
## Purpose
Разбор оборотно-сальдовых ведомостей из XLS/XLSX/XLSB по `05-domain-rules.md` §1: структура определяется по содержимому,
результат — `osvDocument` контракта v1.1, достоверность подтверждается контрольными суммами, словари — данные.
## ADDED Requirements
### Requirement: Листы ОСВ находятся по содержимому
Парсер SHALL считать листом ОСВ любой лист, где хотя бы одна ячейка матчит признак ОСВ из `rules/osv_headers.yaml`
(без учёта регистра, «ё/е», переносов строк и дефисов). Листы без признаков ОСВ SHALL пропускаться с предупреждением
в `metrics.warnings`; лист с признаками ББ/ОФР (§12 п. 1) — с предупреждением о подсрезе 2b. Книга без единого листа ОСВ →
`status = error`, `error.code = INVALID_DOCUMENT`. Несколько листов ОСВ → несколько `documents` с `locator = sheet:<имя>`.
Имя листа MUST NOT использоваться для поиска.
#### Scenario: Один лист — одна ОСВ (все реальные фикстуры)
- **WHEN** разбирается любой файл из `fixtures/osv/**`
- **THEN** `documents` содержит ровно один `osvDocument` с `locator = "sheet:Лист_1"`, статус `done`
#### Scenario: Два листа ОСВ и посторонний лист
- **WHEN** разбирается синтетический файл с листами «ОСВ 60», «Справка», «ОСВ 62»
- **THEN** `documents` содержит два документа в порядке листов, в `warnings` есть запись о пропуске листа «Справка»
#### Scenario: Нет ОСВ
- **WHEN** разбирается книга с одним листом произвольной таблицы
- **THEN** `status = error`, `error.code = INVALID_DOCUMENT`, сообщение называет число просмотренных листов
### Requirement: Заголовок таблицы и колонки сумм определяются по тексту
Парсер SHALL найти блок заголовка таблицы: 1–4 подряд идущих строки, в которых (с учётом объединённых ячеек) встречаются
группы «сальдо на начало», «обороты за период», «сальдо на конец» по синонимам словаря, и под каждой группой подзаголовки
«Дебет»/«Кредит» по синонимам. Допустимы: отсутствие группы «начало» (сальдо на начало `null`), одна колонка в группе без
Дебет/Кредит (свёрнутое сальдо: положительное — дебет, отрицательное — кредит, предупреждение), дополнительные колонки
(«Количество», «Валюта», «ИНН», «КПП», «Код»). Колонка текста строки — первая непустая слева от сумм по строкам данных;
колонка ИНН — по заголовку `ИНН` или колонка, где ≥ 80 % непустых значений — 10 или 12 цифр. Правая граница таблицы —
последняя колонка «Кредит»; всё правее MUST NOT попадать в суммы. Колонки внутри таблицы, не сопоставленные с шестью
суммами и ИНН, SHALL попадать в `rows[].extra` по заголовку. Заголовок не найден или найдено меньше двух групп → `status =
error`, `error.code = INVALID_FORMAT` с названием ненайденной группы. В `columns` SHALL быть записана диагностика (буква
колонки) для каждой из шести сумм и ИНН.
#### Scenario: Плавающие колонки двух организаций
- **WHEN** разбираются `rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx` и `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls`
- **THEN** `columns` первого содержит `opening_debit = "E"`, `closing_credit = "Q"`, второго — `opening_debit = "G"`,
`closing_credit = "S"`, `inn = "E"`; суммы корневой строки совпадают со строкой «Итого»
#### Scenario: Мусор правее таблицы
- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls` (числа в колонках T/U)
- **THEN** эти значения не входят ни в одну из шести сумм, инварианты §1.4 выполнены
#### Scenario: ОСВ без сальдо на начало и со свёрнутым сальдо
- **WHEN** разбирается синтетическая ОСВ с группами только «Обороты» и «Сальдо на конец», где у «конца» одна колонка
- **THEN** `opening_* = null` у всех строк, отрицательное свёрнутое сальдо попадает в `closing_credit`, в `warnings` есть
запись о свёрнутом сальдо, статус `done`
#### Scenario: Неизвестная колонка внутри таблицы
- **WHEN** в синтетической ОСВ между «ИНН» и суммами есть колонка «КПП»
- **THEN** у строк контрагентов `extra["КПП"]` содержит значение, суммы не сдвинуты
### Requirement: Шапка документа и схема группировок
Парсер SHALL извлечь из области выше заголовка таблицы: `organization_name` (первая непустая текстовая строка, trim),
`account_code_raw` (после «по счету»/«по счёту», как в файле: «60», «58.03»), `period_text` (после «за» до «г.»/конца),
`data_type` («Выводимые данные: БУ/НУ/сумма»), `currency` («Валюта …: RUB/руб.» → `RUB`), `header_variant` (1 — группировки
строками первой колонки блока заголовка; 2 — перечисление «субконто: …»). `group_schema` SHALL начинаться с ACCOUNT и далее
перечислять группировки в порядке вложенности по словарю `rules/osv_groups.yaml`; ячейка `ИНН` в строке группировки →
`has_inn = true`. `period_quarter`/`is_cumulative` SHALL вычисляться только по тексту по `rules/periods.yaml` (§1.5,
`ASSUMPTION(OQ-02)`), `period_year` — четыре цифры года. Отсутствие любого поля шапки, кроме обязательных по контракту, —
`null` и предупреждение; отсутствие `organization_name`/`period_text` — пустая строка и предупреждение (контракт требует
строку).
#### Scenario: Вариант 1 с тремя строками группировок
- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls`
- **THEN** `group_schema = [ACCOUNT, DEPARTMENT, COUNTERPARTY(has_inn=true)]`, `header_variant = 1`,
`account_code_raw = "62"`, `period_text = "3 квартал 2024 г."`, `period_quarter = "Q3"`, `is_cumulative = false`,
`data_type = "BU"`, `currency = "RUB"`
#### Scenario: ОСВ по субсчёту и годовой период
- **WHEN** разбираются `rb/2024-Q3/сч 583_3 кв 2024.xlsx` и `rb/2024-FY/ОСВ_66 за 2024.xls`
- **THEN** у первого `account_code_raw = "58.03"`; у второго `period_quarter = "Q4"`, `is_cumulative = true`
#### Scenario: Вариант 2
- **WHEN** разбирается синтетическая ОСВ с шапкой «Период: 1 квартал 2026 г.», «Детализация по субсчетам, субконто:
Контрагенты, Договоры», заголовком «Субконто»
- **THEN** `header_variant = 2`, `group_schema = [ACCOUNT, COUNTERPARTY, CONTRACT]`, `period_quarter = "Q1"`
### Requirement: Иерархия строк — каскад стратегий
Парсер SHALL определять `level` строк данных первой подходящей стратегией из списка: outline level Excel; отступ ячейки
или ведущие пробелы; шаблон кода счёта и жирность; равенство «сумма детей = родитель». Стратегия подходит, если даёт дерево,
где у каждой строки есть предок с меньшим уровнем (кроме уровня 0) и строка «Итого» имеет уровень 0. Выбранная стратегия
SHALL быть записана в `metrics.warnings` как `hierarchy=<имя>`. `parent_row_number` SHALL указывать на ближайшую
предыдущую строку с меньшим уровнем.
#### Scenario: Outline level (реальные файлы)
- **WHEN** разбирается `rb/2024-FY/ОСВ_66 за 2024.xls`
- **THEN** `hierarchy=outline`, строки `66` (0) → `66.01` (1) → контрагент (2); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ»
`parent_row_number` — строка `66.03`
#### Scenario: Без outline level
- **WHEN** разбирается синтетическая копия той же ОСВ без группировок Excel, с отступами ячеек
- **THEN** `hierarchy=indent`, дерево и суммы совпадают с оригиналом
#### Scenario: Без outline и отступов
- **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности
- **THEN** `hierarchy=account_pattern`, инварианты §1.4 выполнены
### 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)`: хранить ли «Итого», решает ядро).
#### Scenario: Подразделение, контрагент с ИНН, физлицо
- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls`
- **THEN** «БП Аренда» → DEPARTMENT; «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» → COUNTERPARTY с `counterparty_inn` из 10 цифр;
«Ведомость в счет з/платы» → COUNTERPARTY с `counterparty_inn = null`
#### Scenario: Название субсчёта в строке счёта
- **WHEN** в синтетической ОСВ строка «60.01, Расчеты с поставщиками»
- **THEN** `group_type = ACCOUNT`, `account_code_raw = "60.01"`, `row_value` — исходный текст
#### Scenario: Строки Вал./Кол.
- **WHEN** в синтетической ОСВ под контрагентом строки «USD» и «Кол.»
- **THEN** `data_type = CURRENCY` и `QUANTITY`, они исключены из проверки «сумма детей = родитель», статус `done`
### Requirement: Числа — Decimal, пусто — null
Суммы SHALL приводиться к `Decimal` с двумя знаками: числовые ячейки — через строковое представление; текстовые —
после удаления пробелов (включая неразрывные и узкие), замены запятой на точку, `(x)` → `-x`; «-», «—», «–», пустая ячейка →
`null` (не 0). Нечисловой текст в колонке сумм → `null` и предупреждение с `row_number`. `float` в результате MUST NOT
использоваться; в JSON суммы — строки `^-?\d+(\.\d{1,2})?$`.
#### Scenario: Текстовые числа
- **WHEN** в синтетической ОСВ суммы записаны как «1 234 567,89», «(1 234,00)», «—»
- **THEN** получены `1234567.89`, `-1234.00`, `null`
### Requirement: Контрольные суммы определяют статус
Для каждого документа парсер SHALL проверить: по каждой строке `opening_debit − opening_credit + turnover_debit −
turnover_credit = closing_debit − closing_credit` (±0.01, `null` = 0); по каждому родителю суммы = Σ детей с тем же
`data_type` по шести колонкам (±0.01); строка TOTAL = корневая строка ACCOUNT. Результат — `checks{total_row_matches_root,
balance_identity_violations, parent_sum_violations}`. Любое нарушение → `status = needs_check` всего результата и запись
причины в `metrics.warnings`; парсер MUST NOT подгонять структуру под сходимость.
#### Scenario: Все реальные файлы сходятся
- **WHEN** разбирается каждый файл `fixtures/osv/**`
- **THEN** `status = done`, `checks.total_row_matches_root = true`, оба списка нарушений пусты
#### Scenario: Испорченная сумма
- **WHEN** в синтетической ОСВ у одного контрагента `closing_debit` увеличен на 1
- **THEN** `status = needs_check`, `balance_identity_violations` содержит его `row_number`, `parent_sum_violations` —
`row_number` родителя, в `warnings` — текст с номерами строк
#### Scenario: Пустая ОСВ
- **WHEN** разбирается `schastl/2025-Q2/…66 за 2 квартал 2025 г..xlsx` (заголовок и сразу «Итого»)
- **THEN** `status = done`, `rows` содержит только строку TOTAL (и строку счёта, если она есть), `metrics.rows = 0` или `1`
по факту файла, предупреждение «нет строк данных»
### Requirement: Форматы, лимиты, время
Парсер SHALL читать XLSX (openpyxl, полное чтение), XLS (xlrd, `formatting_info`), XLSB (pyxlsb; outline level недоступен —
каскад начинается с отступа). Несоответствие `file_type` сигнатуре файла → попытка по сигнатуре и предупреждение.
Файл больше `PARSE_MAX_BYTES` (по умолчанию 50 МиБ) → `error UNSUPPORTED_FORMAT` с текстом про лимит; книга не
открывается → `UNREADABLE`; превышение `PARSE_TIMEOUT_SECONDS` (по умолчанию 120, проверка между листами, стратегиями и
блоками строк) → `TIMEOUT`; непредвиденное исключение → `RECOGNITION_FAILED` с одной строкой причины. Любой файл
`fixtures/osv/**` SHALL разбираться быстрее 5 с.
#### Scenario: XLSB
- **WHEN** разбирается синтетический xlsb той же ОСВ
- **THEN** результат совпадает с xlsx-оригиналом во всём, кроме `hierarchy=indent` и `locator`
#### Scenario: Расширение не совпадает с содержимым
- **WHEN** запрос говорит `XLSX`, а байты — BIFF8
- **THEN** файл прочитан как xls, в `warnings` есть запись о несоответствии, статус по содержимому
#### Scenario: Превышен лимит
- **WHEN** передан файл в 51 МиБ
- **THEN** `error UNSUPPORTED_FORMAT`, книга не открывается
### Requirement: Словари — данные, не код
Признаки листов, синонимы групп колонок и подзаголовков, служебных колонок, маркеров шапки (`rules/osv_headers.yaml`),
группировок (`rules/osv_groups.yaml`), периодов (`rules/periods.yaml`), типов данных и кодов валют (`rules/data_types.yaml`)
SHALL загружаться из YAML и валидироваться pydantic-моделью при старте (ошибка словаря — падение старта с указанием файла и
записи). В `src/parser/` (кроме `grid.py` и `readers/`) MUST NOT быть строковых литералов букв колонок, адресов ячеек,
имён листов и сравнений с номерами строк — проверяется тестом по AST. Каждая запись словаря SHALL срабатывать хотя бы на
одной фикстуре или синтетике (тест покрытия словарей).
#### Scenario: Новый синоним без релиза кода
- **WHEN** в `osv_headers.yaml` добавлен синоним «Нач. остаток» и дана синтетическая ОСВ с таким заголовком
- **THEN** разбор проходит без изменений в `src/`
#### Scenario: Литерал координаты
- **WHEN** в `detect.py` появляется строка `"E9"`
- **THEN** тест-линтер падает с указанием файла и строки
### Requirement: Золотые снимки и property-тесты
Для каждого файла `fixtures/osv/**` SHALL существовать снимок `fixtures/golden/expected_osv_parse/<зеркальный путь>.json`
(= файл результата без `job_id`, `file_id`, `service.version`, `metrics.duration_ms`); тест сравнивает разбор со снимком,
обновление — только явной командой (`make golden-update`) с просмотром diff. Property-тест по всем файлам SHALL проверять
инварианты независимо от снимков (статус ≠ `error`, контрольные суммы, корень = счёт шапки, период распознан). Без
каталога fixtures оба теста пропускаются с причиной (`ARM_KA_FIXTURES`).
#### Scenario: Регрессия
- **WHEN** правка парсера меняет `level` одной строки в любом реальном файле
- **THEN** golden-тест падает с diff по этому файлу
#### Scenario: Новый файл без снимка
- **WHEN** в `fixtures/osv/` добавлен файл без снимка
- **THEN** property-тест его разбирает, golden-тест сообщает «нет снимка: выполните make golden-update»

View File

@ -0,0 +1,19 @@
# Spec Delta: tabular-recognition-service
## MODIFIED Requirements
### Requirement: Парсер — заменяемый интерфейс
Разбор файла SHALL выполняться через единый интерфейс «байты + тип файла → документы/метрики/ошибка», не знающий о
NATS и S3; воркер MUST NOT зависеть от конкретной реализации. Реализация по умолчанию (`get_parser()`) SHALL разбирать
ОСВ по спеке `osv-parsing` и писать файл результата с `schema_version = "1.1"`; листы с признаками ББ/ОФР SHALL
пропускаться с предупреждением до подсреза 2b. Каталог `api/rules/` SHALL содержать словари ОСВ (`osv_headers`,
`osv_groups`, `periods`, `data_types`) и README.
#### Scenario: Подмена реализации в тесте
- **WHEN** в тесте воркеру передан парсер, возвращающий один `osvDocument`
- **THEN** файл результата с этим документом записан в S3, конверт имеет `status = done` и `result_s3` на этот файл
#### Scenario: Реальная ОСВ через воркер
- **WHEN** в интеграционном тесте в S3 лежит `rb/2024-FY/ОСВ_66 за 2024.xls` и опубликован запрос `file_type = XLS`
- **THEN** после ack публикуется конверт `done` с `result_schema_version = "1.1"`, файл результата проходит схему v1.1 и
содержит один `osvDocument` с `account_code_raw = "66"`

View File

@ -0,0 +1,73 @@
# Tasks: Срез 2a — парсер ОСВ (tabular-parser)
Каждая задача ≤ половины дня, начинается с тестов, заканчивается зелёным `make check`. Пути — от корня репозитория.
Фикстуры — `../fixtures` (worktree `.wt/slice-2/fixtures`, ветка `feat/4-slice-2a-osv-parser`); контракт v1.1 —
`../docs/contracts` (ветка `feat/9-slice-2a-osv-parser`).
## 1. Основание
- [ ] 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`
не ломается. Проверка: `pytest -m fixtures --collect-only` показывает 66 параметров.
- [ ] 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`.
- [ ] 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, ...]` с
определением по сигнатуре и предупреждением при несоответствии; ошибки → `UnreadableError`/`UnsupportedFormatError`.
Тесты: три читателя на одном синтетическом листе дают одинаковые ячейки; реальные xls/xlsx читаются с outline;
`fixtures`-тест: все 66 файлов читаются < 5 с каждый.
- [ ] 1.4 Словари: `api/rules/{osv_headers,osv_groups,periods,data_types}.yaml` (содержимое по §1.1/§1.5 и синонимам из §1.0),
`src/parser/rules.py:load_rules(path) -> Rules` (pydantic, компиляция регулярок с нормализацией ё/е и пробелов),
тест на падение при битом словаре. Покрытие словарей — задача 4.3.
## 2. Обнаружение структуры
- [ ] 2.1 `detect.py`: `find_osv_sheets`, `find_table_header` (группы по merged-тексту в 1–4 строках, Дебет/Кредит под
группой, свёрнутое сальдо, отсутствие «начала», `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`.
- [ ] 2.2 `header.py`: шапка документа (организация, счёт, период, data_type, валюта), `group_schema` вариант 1 и 2, `has_inn`,
период → `period_year`, `period_quarter`, `is_cumulative` по `periods.yaml` (`ASSUMPTION(OQ-02)`). Unit-тесты: обе шапки,
отсутствие полей → `null` + warning, субсчёт 58.03, все строки таблицы §1.5. `fixtures`-тест: у всех 66 файлов
`account_code_raw` совпадает с кодом в имени каталога/файла по регулярке и период распознан.
- [ ] 2.3 `hierarchy.py`: стратегии `outline`, `indent`, `account_pattern`, `sums`, каскад с проверкой непротиворечивости,
`parent_row_number`. Unit-тесты на сетках для каждой стратегии и для отказа стратегии (одинаковые уровни); `fixtures`-тест:
`hierarchy=outline` у всех 66.
- [ ] 2.4 `classify.py` + `numbers.py`: классификация (ACCOUNT по регулярке, позиция в `group_schema`, OTHER при выходе,
TOTAL, COUNTERPARTY с ИНН, Вал./Кол./код валюты → `data_type`), числа (Decimal, текстовые форматы, скобки, прочерки,
экспонента), `extra`. Unit-тесты по §1.3 и §1.2; `fixtures`-тест: в `schastl …62 за 3 кв 2024` «БП Аренда» → DEPARTMENT,
«МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» с ИНН, физлицо без ИНН.
## 3. Сборка и статусы
- [ ] 3.1 `checks.py` + `osv.py`: инварианты §1.4 (±0.01, `null` = 0, CURRENCY/QUANTITY исключены), `checks`, статусы D-6,
`Deadline` и `PARSE_MAX_BYTES`/`PARSE_TIMEOUT_SECONDS` в `Settings` (+ `.env.example`), `OsvParser.parse(data, file_type)
-> ParseOutcome`, `get_parser()` возвращает его, `NullParser` остаётся только для тестов воркера. Unit-тесты: карта
статусов (нет листов, нет заголовка, лимит, таймаут через монкипатч часов, исключение → RECOGNITION_FAILED), испорченная
сумма → `needs_check` с номерами строк. `fixtures`-тест (property): все 66 — `done`, инварианты, корень = счёт шапки.
- [ ] 3.2 Интеграция с воркером: `test_worker` на реальном `OsvParser` с байтами `ОСВ_66 за 2024.xls` (unit, без NATS) и
интеграционный roundtrip (NATS + MinIO): конверт `done`, `result_schema_version = "1.1"`, файл результата проходит схему.
README: раздел «Запуск на файле» (`python -m src.parser.cli <файл>` — печать результата JSON; добавить CLI).
## 4. Синтетика, golden, линтер
- [ ] 4.1 Генератор синтетики `tests/synthetic/generate.py` (детерминированный openpyxl) → `../fixtures/osv/synthetic/`: вариант 2
шапки; без outline (отступы); без outline и отступов; Вал./Кол.; текстовые числа и скобки; два листа ОСВ + посторонний;
без «начала» + свёрнутое сальдо; КПП-колонка; название субсчёта в строке; испорченная сумма; плюс один xlsb, сконвертированный
вручную из синтетического xlsx и закоммиченный с пометкой в README fixtures. Тесты сценариев спеки на этих файлах.
- [ ] 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) — сверка описывается в отчёте задачи.
- [ ] 4.3 Линтер координат `tests/unit/test_no_coordinates.py` (AST по `src/parser/**` кроме `grid.py`, `readers/`) и тест
покрытия словарей (каждая запись сработала хотя бы раз на фикстурах+синтетике; список несработавших — в сообщении).
## 5. Завершение
- [ ] 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`.