Andrey Epifancev 1eb57da5cb fix(readers): предсканирование xlsx без sniff — expat по всем частям архива, потолок крупных merged в xls (I-1б)
Предскан объединённых ячеек xlsx отдаёт expat каждую некаталожную запись zip:
отбор «похоже на XML» по первым 64 байтам обходился листом в части без .xml
с ведущими пробелами и без XML-декларации (1,9 КБ → > 3 с, у ревьюера 27 с /
1,4 ГБ). Части *.xml — строго, остальные — мягко (ошибка разбора лишь
заканчивает скан части). После чтения любого формата число диапазонов листа
площадью > MAX_INDEXED_AREA ограничено LARGE_MERGED_COUNT_LIMIT = 1000:
Sheet.merged_span перебирает их линейно. README, design.md №39, дельта спеки.

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

178 lines
37 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 | 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 `ARM_KA_FIXTURES` поддержан хелпером фикстур, `scan-gateway` — follow-up). Новые вопросы внесены в `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` в обоих случаях; дефект принят, не исправлен | малое влияние, правка требует различать область шапки и строку детализации | ложное предупреждение у ОСВ с группировкой «Валюта» |
| 35 | 2026-10-05 | R-23: чтение xlsx ограничено: (а) сумма несжатых размеров записей zip ≤ 10 × PARSE_MAX_BYTES, (б) до openpyxl сканируются `<mergeCell ref>` и суммарная площадь merged-диапазонов ограничена именованной константой, иначе UNSUPPORTED_FORMAT с текстом лимита, (в) `deadline.check` в циклах detect; `defusedxml` не добавляется (новая зависимость — решение платформы) → follow-up | финальное ревью I-1: файл 4,9 КБ с одним mergeCell A1:Z200000 даёт 24 с и 1,4 ГБ; воркер не прервать | прочие XML-бомбы до внедрения defusedxml возможны |
| 36 | 2026-10-05 | R-24: значениям-основанное определение колонки ИНН не дополняет 9/11-значные числа нулём (дополнение — только при извлечении значения из колонки, выбранной по заголовку или по строгому правилу 80 % из 10/12 цифр) | финальное ревью M-3: числовая колонка КПП (9 цифр) молча принималась за ИНН (counterparty_inn = «0»+КПП) | числовая колонка ИНН с потерянными нулями без заголовка «ИНН» не определится автоматически |
| 37 | 2026-10-05 | R-25: вторая и последняя волна правок по I-1(б): предсканирование merge-диапазонов заменяется потоковым разбором `xml.parsers.expat` по ВСЕМ `.xml`-частям архива (namespace-aware, charref, кавычки), раннее прерывание при превышении площади, битый XML → UNREADABLE; после-чтение проверка merged считает только диапазоны, которые grid разворачивает (≤ порога индексации); остаточные риски (объём ячеек, sharedStrings/styles, entity expansion) — документируются, лимиты памяти контейнера/defusedxml — решение платформы | регулярочный скан обходился четырьмя способами из файла < 5 КБ (24 с, 1,4 ГБ) | бомба по числу ячеек (5 МБ, 2 млн ячеек → 13 с, 1,5 ГБ) и entity expansion остаются до решений платформы |
| 38 | 2026-10-05 | R-26: третьей волны правок нет; предсканирование merge-диапазонов — эшелон защиты, а НЕ граница безопасности: закрывает обычную бомбу и все найденные стандартные обходы, но намеренно собранный файл (лист в части без `.xml` + ≥ 64 байт ведущих пробелов, без XML-декларации) доходит до openpyxl (27 с / 1,4 ГБ); настоящая граница — лимит памяти контейнера и/или подпроцесс с RLIMIT (решение платформы, OQ-108). Первый follow-up: убрать «sniff», лениво скармливать expat каждую не-каталожную часть + тест; ограничить число больших merged-диапазонов (xls) | ревью финальной волны, раунд 2 | специально собранный файл занимает воркер ~30 с / 1,4 ГБ до follow-up или лимита памяти платформы; файлы приходят от аутентифицированных пользователей внутренней ИС |
| 39 | 2026-10-05 | I-1(б) закрыт: sniff убран, expat по всем частям; потолок крупных merged xls. Предскан xlsx отдаёт `xml.parsers.expat` КАЖДУЮ некаталожную запись архива без отбора по имени или первым байтам (части `*.xml` — строго, битый XML → UNREADABLE; остальные — мягко, ошибка разбора заканчивает скан части без отказа файла). После чтения любого формата число крупных merged-диапазонов листа (площадь > `grid.MAX_INDEXED_AREA` = 10 000, индекс их не раскладывает) — не больше `LARGE_MERGED_COUNT_LIMIT` = 1000, иначе UNSUPPORTED_FORMAT с текстом лимита. «Крупный» взят по существующему порогу индекса, а не по запасному «≥ 1000 ячеек / потолок 10 000», потолок — по предложению ревью (≤ 1000) | follow-up R-26 (ревью финальной волны, раунд 2): crafted-файл 1,9 КБ (лист в `xl/worksheets/sheet1.dat`, 64 пробела, без декларации) читался > 3 с (ревьюер: 27 с / 1,4 ГБ), после — отказ < 1 мс; 100 000 крупных диапазонов xls — ≈ 3,2 мс на обращение к `merged_span`, при 1000 — ≈ 31 мкс | расхождение expat скана и разбора openpyxl на части не `*.xml` (скан остановился раньше) вернёт обход — известных нет; законная книга с > 1000 диапазонами > 10 000 ячеек на листе отвергнется UNSUPPORTED_FORMAT (в реальных книгах — единицы); предскан по-прежнему не граница безопасности (OQ-108) |