# 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 | date | datetime | bool | None, kind: Literal["empty","text","number","date","bool"], bold: bool, indent: int)`, `Sheet(name, cells: Mapping[(row, col), Cell], merged: tuple[MergedRange, ...], row_outline: Mapping[int, int], n_rows, n_cols)`. Хранятся только непустые ячейки; ячейки из одних пробелов не хранятся в индексе строк. Методы: `cell(r, c)`, `text_at(r, c)` (внутри merged — текст origin), `merged_origin(r, c)`, `merged_span(r, c)`, `row_cells(r)` (непустые `(col, cell)` по возрастанию колонки), `is_row_empty(r)`, `outline(r)`. Индексы 1-based, как в Excel, для диагностики (`row_number` контракта). Читатели (`readers/xlsx.py`, `xls.py`, `xlsb.py`) возвращают `tuple[Sheet, ...]`, а `read_book(data, file_type) -> (sheets, warnings)` выбирает читателя по сигнатуре байтов; числа — `Decimal(str(value))` без квантования (квантование до 2 знаков только для сумм, в `numbers.py`). *Альтернатива* — работать напрямую с объектами openpyxl/xlrd: три разных API в детекторах, невозможно тестировать на синтетических сетках. *Цена ошибки:* модель расширяется полями, адаптеры тонкие. ### D-2. Детекторы — чистые функции над `Sheet`, результат — индексы, а не буквы `detect.classify_sheet(sheet, rules) -> SheetKind` (ОСВ / форма ББ-ОФР / прочее); `detect.find_table_header(sheet, rules) -> TableHeader(first_row, last_row, columns: ColumnMap(шесть индексов; `None` у «начала», если его нет), text_col, inn_col | None, right_edge, extra_cols, group_label_rows)`. Поиск групп — по тексту ячеек в пределах 1–4 подряд идущих строк с учётом merged (значение берётся из origin), «Дебет/Кредит» ищутся в следующей строке внутри span группы. **Строка групп обязана содержать «обороты» и «сальдо на конец»; отсутствовать может только «начало»** (R-12, design №22): кандидат без них пропускается, при отсутствии подходящего — `INVALID_FORMAT` с названием недостающей группы. Одна колонка в группе без Дебет/Кредит → свёрнутое сальдо (знак = сторона) с предупреждением. Всё правее `right_edge` игнорируется (§1.2). В `columns` контракта пишутся буквы колонок **только как диагностика** (функция `column_letter(index)` в `grid.py`), код парсера по буквам ничего не ищет. ### D-3. Каскад стратегий иерархии `hierarchy.levels(sheet, body_rows, header) -> Levels(by_row: dict[int,int], strategy: str)`. Порядок: `outline` (если ≥ 2 различных уровней среди строк данных) → `indent` (`Cell.indent` или ведущие пробелы, ≥ 2 различных) → `account_pattern` (строка-счёт ранга по числу сегментов кода, не-счёт = ранг последнего счёта + 1 + глубина по жирности) → `sums` (строка — родитель следующих, пока сумма детей не совпадёт по шести колонкам). Первая стратегия, давшая непротиворечивое дерево (каждая строка имеет родителя с меньшим уровнем), побеждает; имя — в `metrics.warnings` как `hierarchy=` всегда (чтобы golden фиксировал стратегию). Строка «Итого» стратегиям **не передаётся**: она всегда уровень 0 без родителя (R-14, design №24). Если строк данных (кроме «Итого») меньше двух — стратегия `trivial` (дерево из корней; пустая ОСВ, R-13, design №23); `outline` у всех файлов со строками данных, `trivial` — у пустых. *Цена ошибки:* порядок стратегий меняется в одном списке. ### D-4. Классификация и `group_schema` `header.group_schema(sheet, table_header, rules)`: вариант 1 — строки первой колонки блока заголовка сверху вниз, первая всегда ACCOUNT; вариант 2 — «субконто: A, B» через запятую после первого ACCOUNT; ячейка `ИНН` в строке группировки → `has_inn`. `classify.rows(...)`: счёт по `ACCOUNT_RE` (две-три цифры и до двух сегментов субсчёта, где сегмент — `\d{1,2}` или 1–3 ЗАГЛАВНЫЕ буквы без IGNORECASE: «76.АВ», R-16; после запятой допустим `\s*`, R-18), нормализация кода (`60.1 → 60.01`) в `account_code_raw` остаётся как в файле (контракт), нормализованный код — забота ядра; не-счёт → группа по позиции (количество не-счётных предков после последнего ACCOUNT); для COUNTERPARTY `counterparty_name = row_value`, `counterparty_inn` — только из колонки ИНН (цифры из текстовой ячейки, результат при 10 или 12 цифрах, иначе `null`; ИНН внутри текста строки не извлекается — R-15, R-17); строки «Вал.», «Кол.», трёхбуквенные коды валют → `group_type` OTHER, `data_type` CURRENCY/QUANTITY, без позиции в схеме и без `counterparty_*` (`account_code_raw` наследуется от ветки, R-19); их суммы остаются в шести полях, `data_type` лишь помечает строку, и в инвариантах §1.4 такие строки исключаются. Последняя строка level 0 с текстом из словаря «Итого» → `TOTAL`. ### D-5. Числа `numbers.to_money(cell) -> Decimal | None`: число → `Decimal(str(value)).quantize(0.01)`; текст → убрать пробелы (в т.ч. 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`. Сверка «сумма детей = родитель»: обороты — по колонкам; сальдо — по колонкам либо по нетто, если у родителя оно свёрнуто (R-21, design №31). «Итого» не найдена — предупреждение и `total_row_matches_root = false` (R-20, design №30). Пустая ОСВ — `done` с `checks.total_row_matches_root = true` (R-13). Несколько листов ОСВ → несколько документов, лист с признаками ББ/ОФР (§12 п. 1) → пропуск с предупреждением `sheet= skipped: statement (slice 2b)`. ### D-7. Словари — pydantic-модели из YAML, загружаются один раз `rules.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 — один файл `s13`, собранный тестовым писателем BIFF12 (`tests/synthetic/xlsb_writer.py`; LibreOffice нет, R-9), а не сконвертированный в Excel: совместимость с настоящим xlsb не доказана (O-7). Линтер `test_no_coordinates.py`: AST `src/parser/**` кроме `grid.py` и `readers/` — запрещены строковые литералы `^[A-Z]{1,3}\d*$` длиной ≤ 4, литералы с «Лист», сравнения `row == ` / индексация `[]` по `Sheet`. ## Risks / Trade-offs - [`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 (закрыт хелпером фикстур tabular-parser). Новые вопросы внесены в `07-open-questions.md` задачей 13 (OQ-98…OQ-105: коды валют CURRENCY-строк, несколько ОСВ одного счёта в книге, ИНН внутри текста строки и «ИНН/КПП», числа с «+»/«.5»/«5.», xlsb из Excel, буквенный первый сегмент счёта, «Валюта» в строке детализации, подтверждение сверки по нетто). ## 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: непроверенные числовые утверждения плана о реальных файлах проверяет исполнитель; неверное — правится общим правилом, без таблицы исключений | фикстуры — не оракул | нет | | 21 | 2026-10-05 | R-11: расхождения формулировок D-1 и tasks.md 1.3 с построенным API (`read_book -> (sheets, warnings)`, `bool` kind, методы Sheet) правятся в задаче 13; тест «три читателя дают одинаковые ячейки» заменён тестами по читателю + реальные файлы (нет писателей xls/xlsb) | план — детальный контракт, спека описывала ранний эскиз | расхождение значений ячеек между читателями проявится только на golden | | 22 | 2026-10-05 | R-12: строка групп заголовка ОСВ обязана содержать «обороты» и «сальдо на конец» (отсутствовать может только «начало»); иначе строка пропускается, а при отсутствии подходящей — INVALID_FORMAT с названием недостающей группы | спека допускает отсутствие только «начала»; «≥ 2 из 3» из плана принимало заголовок без оборотов/конца и ложный заголовок из преамбулы | файл без «конца» отдаст INVALID_FORMAT вместо частичного разбора | | 23 | 2026-10-05 | R-13: пустая ОСВ (кроме «Итого» строк данных нет) или с единственной строкой данных — штатный случай: `hierarchy=trivial` (код: меньше двух строк данных); TOTAL = последняя непустая строка уровня 0 с текстом «Итого», суммы не требуются; утверждения плана «у всех 66 outline», «rows>0 кроме одного файла» обобщены (outline — для файлов со строками данных; пустые определяются свойством, а не списком; в фикстурах таких два: schastl 58 и 66, 2 кв 2025) | спека требует сценарий «Пустая ОСВ»; план предполагал один такой файл | пустая ОСВ дала бы INVALID_FORMAT | | 24 | 2026-10-05 | R-14: «Итого» не передаётся стратегиям иерархии, всегда уровень 0, родитель None (для всех четырёх стратегий) | иначе `account_pattern` вкладывает «Итого» в последний счёт и сценарий «без outline и отступов» невозможен; для outline/indent теряется слабая проверка согласованности | outline/indent примут лист с «Итого» на ненулевом уровне | | 25 | 2026-10-05 | R-15: ИНН из текстовой ячейки — берутся цифры («ИНН 352819549855» → `352819549855`), результат только при 10 или 12 цифрах, иначе null; для числовых ячеек — дополнение ведущим нулём (T4.3) | спека: «только цифры длиной 10 или 12 из колонки ИНН»; тест-кейс брифа Task 7 | контрагент с текстовым префиксом получит null-ИНН | | 26 | 2026-10-05 | R-16 (уточнён по ревью): сегмент субсчёта в `ACCOUNT_RE` — `\d{1,2}` или 1–3 ЗАГЛАВНЫЕ буквы кириллицы (с Ё) / латиницы, без IGNORECASE (цифровые сегменты остаются 1–2 знака: «60.001» — не счёт): «76.АВ», «76.ВА», «76.ОТ» — строки ACCOUNT; ранг = число сегментов | в 8 из 66 реальных файлов (РБ 76, schastl 76 за 2024 Q1–Q3) такие строки иначе становятся COUNTERPARTY и сдвигают тип детей; регулярка спеки с `\.\d{1,2}` реальным данным не соответствует | контрагент с именем вида «76.АВ» примут за счёт (маловероятно) | | 27 | 2026-10-05 | R-17: `counterparty_inn` — только из колонки ИНН (docs §1.3), иначе null; ИНН внутри текста строки («Полиграф, 3528315710», 20 строк schastl 76) парсер не извлекает, `counterparty_name` = весь `row_value`; пример контракта `result-osv-needs-check.json` приводится в соответствие (задача 13); вопрос про ИНН из имени — в 07-open-questions | docs §1.3 и спека: «только цифры из колонки ИНН, иначе null» | arm-core сам разбирает ИНН из имён | | 28 | 2026-10-05 | R-18: после запятой в `ACCOUNT_RE` допустим `\s*` (спека: `\s`); текст спеки правится в задаче 13 | допуск «60,Расчеты» без пробела безвреден; в фикстурах не встречается | «60,Расчеты» примут за счёт | | 29 | 2026-10-05 | R-19: строки «Вал.»/«Кол.»/код валюты — `group_type` OTHER, `data_type` CURRENCY/QUANTITY, не занимают позицию в схеме и НЕ наследуют `counterparty_name/inn` (`account_code_raw` наследуется от ветки); пример контракта правится в задаче 13 | поля контрагента — только для строк COUNTERPARTY (спека); docs §1.3 | consumers ядра ищут контрагента по родителю | | 30 | 2026-10-05 | R-20: если строка «Итого» не найдена — предупреждение «строка «Итого» не найдена» и `checks.total_row_matches_root = false` (→ needs_check) | сверку с корнем выполнить нельзя; консервативно — на проверку аналитику | файл без «Итого» всегда needs_check | | 31 | 2026-10-05 | R-21: сверка «сумма детей = родитель» (§1.4): обороты — по колонкам; сальдо — по колонкам ЛИБО, если у родителя сальдо свёрнуто (не более одной ненулевой стороны), по нетто «дебет − кредит» | 1С показывает счёт нетто (62 = 16,1 млн Дт), а субсчета развёрнуто (62.01 = 40,2 млн Дт, 62.02 = 24,1 млн Кт): 32 из 66 реальных файлов не сходились по шести колонкам только из-за этого; все 62 родителя проверены — обороты и нетто сходятся; негативы закреплены тестами; текст §1.4 и спеки правится в задаче 13 | перестановка сторон при совпавшем нетто родителя не ловится этой проверкой (ловят тождество строки и «Итого = корень») | | 32 | 2026-10-05 | R-22: тест покрытия словарей считает записи распознавания (паттерны osv_headers/osv_groups/periods/data_types); перечень кодов валют ISO (`currency_codes`) исключён — это список значений, а не синонимы; несработавшие паттерны удаляются из YAML или покрываются новым синтетическим файлом s14_* | список ISO нельзя навязать фикстурам | мёртвые коды валют остаются в списке | | 33 | 2026-10-05 | O-7: `s13.xlsb` собран тестовым писателем BIFF12, а не Excel; совместимость с настоящим xlsb не доказана, замена — по инструкции в README fixtures | R-9, нет LibreOffice/Excel на машине | чтение xlsb из Excel может отличаться от проверенного; проявится на первом реальном xlsb | | 34 | 2026-10-05 | O-8 (T12.1): строка детализации варианта 2 с группировкой «Валюта» совпадает с меткой валюты шапки и даёт ложное предупреждение «валюта «» не распознана» (в снимке s16); `currency = null` в обоих случаях; дефект принят, не исправлен | малое влияние, правка требует различать область шапки и строку детализации | ложное предупреждение у ОСВ с группировкой «Валюта» |