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:
parent
277e01d205
commit
a6fd2d0535
2
openspec/changes/slice-2a-osv-parser/.openspec.yaml
Normal file
2
openspec/changes/slice-2a-osv-parser/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-05
|
||||||
134
openspec/changes/slice-2a-osv-parser/design.md
Normal file
134
openspec/changes/slice-2a-osv-parser/design.md
Normal 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 | парсер не видит других файлов и БД | правка ядра |
|
||||||
63
openspec/changes/slice-2a-osv-parser/proposal.md
Normal file
63
openspec/changes/slice-2a-osv-parser/proposal.md
Normal 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.
|
||||||
207
openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md
Normal file
207
openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md
Normal 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»
|
||||||
@ -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"`
|
||||||
73
openspec/changes/slice-2a-osv-parser/tasks.md
Normal file
73
openspec/changes/slice-2a-osv-parser/tasks.md
Normal 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`.
|
||||||
Loading…
x
Reference in New Issue
Block a user