157 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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: непроверенные числовые утверждения плана о реальных файлах проверяет исполнитель; неверное — правится общим правилом, без таблицы исключений | фикстуры — не оракул | нет |
| 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 нельзя навязать фикстурам | мёртвые коды валют остаются в списке |