Andrey Epifancev 7738221b5a docs: README/CLAUDE.md Среза 2a, tasks.md, спека-дельты
- README: парсер по модулям, статусы и коды, словари и добавление синонима, лимиты, golden-процесс, настройки PARSE_*
- CLAUDE.md: раздел «Добавлено в Срезе 2a» (модули, правила расширения, линтер, R-21); api/rules/README.md: ключи словарей
- openspec: tasks.md (5.1), design.md (D-1…D-7, O-7/O-8), спека-дельты osv-parsing (R-12…R-22) и tabular-recognition-service («Результат по контракту»)
- tests: check_snapshot в golden_helpers + тесты «нет снимка»/«регрессия»; locator в property-тесте; пример needs_check синхронизирован с docs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 15:53:11 +04:00

31 KiB
Raw Blame History

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=<strategy> всегда (чтобы 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=<name> 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 == <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 (закрыт хелпером фикстур 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 в обоих случаях; дефект принят, не исправлен малое влияние, правка требует различать область шапки и строку детализации ложное предупреждение у ОСВ с группировкой «Валюта»