Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
145 lines
20 KiB
Markdown
145 lines
20 KiB
Markdown
# 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; их суммы остаются в шести полях, `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 | парсер не видит других файлов и БД | правка ядра |
|
||
| 11 | 2026-10-05 | R-1: свёрнутое (одноколоночное) сальдо раскладывается по знаку в `classify.collect_body_rows` (плюс → дебет, минус → кредит), одно предупреждение «свёрнутое сальдо» на лист | сценарий спеки требует, ни одна задача плана не владела | перенести в `osv.py` |
|
||
| 12 | 2026-10-05 | R-2: fixtures-тест иерархии на реальных файлах пишется в задаче 8 (на `collect_body_rows`), в задаче 6 — только unit | `BodyRow` строится из классификатора, который появляется в 8 | дефекты стратегий на реальных файлах всплывут позже |
|
||
| 13 | 2026-10-05 | R-3: `deadline.py` создаётся в задаче 6, не 9 | `resolve_levels` и `collect_body_rows` принимают `Deadline` раньше задачи 9 | нет |
|
||
| 14 | 2026-10-05 | R-4: линтер координат запрещает литерал, целиком совпадающий с именем листа по умолчанию (`(?i)^(лист\|sheet)[\s_]*\d*$`), а не любой литерал со словом «лист» | D-6 требует сообщений вида «лист ОСВ без заголовка» | пропустит имя листа внутри длинной строки |
|
||
| 15 | 2026-10-05 | R-5: линтер не трогает константы в аннотациях и значения enum/Literal контракта (вычисляются из `src.contract`) | значения контракта (BU, Q1, XLS…) — структура, не координаты | буква колонки, равная значению enum, проскочит |
|
||
| 16 | 2026-10-05 | R-6: в `data_types.yaml` добавлен `currency_aliases` (RUB → руб/рубл/rub), код `RUB` не литерал | синонимы — данные | переименование поля |
|
||
| 17 | 2026-10-05 | R-7: AST-линтер создаётся в задаче 3, не в 12 | ловить нарушения по мере появления кода | задача 3 крупнее |
|
||
| 18 | 2026-10-05 | R-8: тест сценария «Новый синоним» (tmp-копия словарей + «Нач. остаток») в задаче 9; в штатном словаре этого синонима нет | сценарий спеки без теста в плане | нет |
|
||
| 19 | 2026-10-05 | R-9: xlsb — допускается тестовый минимальный писатель BIFF12 (нет LibreOffice), иначе `deferred` + skip | сценарий спеки XLSB | слабее доказательство совместимости с xlsb из Excel |
|
||
| 20 | 2026-10-05 | R-10: непроверенные числовые утверждения плана о реальных файлах проверяет исполнитель; неверное — правится общим правилом, без таблицы исключений | фикстуры — не оракул | нет |
|