## MODIFIED Requirements ### Requirement: Контрольные суммы определяют статус Для каждого документа парсер SHALL проверить: по каждой строке `opening_debit − opening_credit + turnover_debit − turnover_credit = closing_debit − closing_credit` (±0.01, `null` = 0); по каждому родителю суммы = Σ детей с тем же `data_type` (±0.01), причём **обороты** сверяются по колонкам, а **сальдо** (начало, конец) — по колонкам либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто «дебет − кредит» (R-21: 1С показывает счёт нетто, а субсчета и контрагентов — развёрнуто; остаточные риски перечислены ниже); строки CURRENCY/QUANTITY в суммирование не входят и сами родителями не проверяются; строка TOTAL = корневая строка ACCOUNT (первая строка уровня 0). Если корня нет (пустая ОСВ, R-13), «Итого» SHALL быть пустой или нулевой. Если строка «Итого» не найдена, `total_row_matches_root = false` и в `warnings` — «строка «Итого» не найдена» (R-20). Результат — `checks{total_row_matches_root, balance_identity_violations, parent_sum_violations}`. Результат проверки SHALL записываться в документ листа (контракт v2.0): `status` листа — `done`, когда контрольные суммы §1.4 сошлись (и строки ИНН без нарушений), иначе `needs_check` (прежние условия §1.6, применённые к листу); любое нарушение → `status = needs_check` документа и запись причины в `warnings[]` документа. Предупреждения листа SHALL быть структурами `{code, severity, message}` с `severity = info` и кодом с префиксом `OSV_` (`OSV_HIERARCHY_BY_<СТРАТЕГИЯ>`, `OSV_HEADER`, `OSV_ROWS`, `OSV_INN_ROWS`, `OSV_NO_DATA_ROWS`, `OSV_BALANCE_IDENTITY`, `OSV_PARENT_SUM`, `OSV_TOTAL`, `OSV_XLSB_NO_OUTLINE`); строк вида `sheet=<имя> <текст>` в `metrics.warnings` больше нет. Парсер MUST NOT подгонять структуру под сходимость. Исключение — **счета без сальдо по аналитике** (решение владельца 07.10.2026, OQ-132): у 91 и 99 строки аналитики несут только обороты, сальдо закрыто на субсчёт закрытия (91.09). Список счетов — данные `rules/osv_checks.yaml` (`accounts_without_analytic_balance`, начальный состав `91`, `99`; 90 не входит). Строка относится к такому счёту, если код ближайшего счёта её ветки (`account_code_raw`) совпадает с записью списка либо является её субсчётом (`91`, `91.01`, но не `910`). Для таких строк тождество строки SHALL NOT проверяться; сверка «сумма детей = родитель» сохраняется: обороты — всегда по колонкам, сальдо — только у родителя с дочерними субсчетами (корень → субсчета), а у родителя с одной лишь аналитикой сальдо не сверяется (сальдо аналитики пусто по определению); «Итого» сверяется как обычно. Остаточные риски (известные слепые зоны, не ловятся проверками §1.4): - RR-1 — у свёрнутого родителя сверяется только нетто сальдо: распределение сальдо детей между дебетом и кредитом (перестановка сторон при совпавшем нетто) не проверяется. - RR-2 — «свёрнутость» определяется по самому родителю: развёрнутое сальдо родителя, у которого в файле потеряна одна сторона, неотличимо от нетто-показа 1С и проходит, если нетто совпало. - RR-3 — нетто допускается на любом уровне дерева (счёт, субсчёт, контрагент, договор), а не только на уровне «счёт → субсчета», где его показывает 1С: ошибка распределения сальдо между договорами под свёрнутым контрагентом не ловится. - RR-4 — тождество строки сверяет только нетто строки, «Итого = корень» — только корень: ошибка, сохраняющая нетто строки, ловится лишь сверкой с родителем по колонкам, а под свёрнутым родителем — ничем (см. слепую зону ниже). - Слепая зона: обе стороны сальдо (на начало или на конец) развёрнутого ребёнка увеличены на одно и то же X, а его непосредственный родитель свёрнут — не ловится ничем: достаточно свёрнутого прямого родителя (сверка с ним — по нетто, а нетто ребёнка не изменилось; значения родителя и выше не затронуты; тождество строки и «Итого = корень» сохраняются). - Родитель с сальдо (0, 0) при детях (X Дт, X Кт) принимается: нулевое сальдо — свёрнутое, нетто детей X − X = 0 (закреплено тестом как осознанное поведение R-21). - Вне R-21: текст вида «12.ООО» (2–3 цифры, точка, до трёх заглавных букв) принимается за счёт (R-16) и меняет `group_type` строк ветки; суммы и проверки это не затрагивает. #### Scenario: Все реальные файлы сходятся - **WHEN** разбирается каждый файл `fixtures/osv/**` - **THEN** `status = done`, `checks.total_row_matches_root = true`, оба списка нарушений пусты #### Scenario: Испорченная сумма - **WHEN** в синтетической ОСВ у одного контрагента `closing_debit` увеличен на 1 - **THEN** `status = needs_check` документа, `balance_identity_violations` содержит его `row_number`, `parent_sum_violations` — `row_number` родителя, в `warnings[]` документа — `OSV_BALANCE_IDENTITY` / `OSV_PARENT_SUM` (`info`) с номерами строк #### Scenario: Сальдо родителя свёрнуто нетто - **WHEN** у родителя сальдо показано одной стороной (нетто), а у детей — обеими сторонами, и нетто детей равно нетто родителя - **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто или расхождение оборотов — нарушение #### Scenario: Счёт 91 — аналитика только с оборотами - **WHEN** разбирается ОСВ по счёту 91: строки аналитики 91.01, 91.02, 91.09 несут только обороты, сальдо закрыто на субсчёт закрытия (`fixtures/osv-dkb/tsimlyansky/2025-Q3/91_ 9мес.2025.xls`) - **THEN** `status = done`, оба списка нарушений пусты; обороты аналитики при этом сходятся с оборотами субсчёта, а расхождение оборотов, сальдо субсчетов с корнем или «Итого» осталось бы нарушением #### Scenario: «Итого» не найдена - **WHEN** в таблице нет строки с текстом из словаря «Итого» - **THEN** `total_row_matches_root = false`, `status = needs_check`, в `warnings[]` — `OSV_TOTAL` (`info`) «строка «Итого» не найдена» #### Scenario: Пустая ОСВ - **WHEN** разбираются реальные пустые ОСВ (в фикстурах две: `schastl/2025-Q2/…58 за 2 квартал 2025 г..xlsx` и `…66 за 2 квартал 2025 г..xlsx`: заголовок и сразу «Итого») либо любой файл, где кроме «Итого» строк данных нет - **THEN** `status = done` документа, `rows` содержит только строку TOTAL, `metrics.rows = 0` (строки данных без TOTAL), `hierarchy=trivial`, `checks.total_row_matches_root = true` (корня нет, суммы TOTAL пусты), предупреждение `OSV_NO_DATA_ROWS` «нет строк данных» #### Scenario: Предупреждение ОСВ - **WHEN** на листе ОСВ есть строка без родителя - **THEN** у документа ОСВ предупреждение `{code: "OSV_…", severity: "info", message}`, статус документа по §1.6; в `metrics.warnings` конверта строки `sheet=…` нет ### Requirement: Форматы, лимиты, время Парсер SHALL читать XLSX (openpyxl, полное чтение), XLS (xlrd, `formatting_info`), XLSB (pyxlsb; outline level недоступен — каскад начинается с отступа). Несоответствие `file_type` сигнатуре файла → попытка по сигнатуре и предупреждение файла `FILE_TYPE_MISMATCH` (`severity = info`) в `metrics.warnings`. Файл больше `PARSE_MAX_BYTES` (по умолчанию 50 МиБ) → `error UNSUPPORTED_FORMAT` с текстом про лимит. До вызова библиотеки чтения SHALL действовать лимиты чтения (R-23) → `error UNSUPPORTED_FORMAT` с текстом про лимит: сумма объявленных несжатых размеров записей zip (xlsx/xlsb) не больше `ZIP_UNCOMPRESSED_LIMIT_FACTOR × PARSE_MAX_BYTES` (10 ×); суммарная площадь объединённых ячеек книги не больше `MERGED_AREA_LIMIT` (200 000 ячеек). Для xlsx — до openpyxl потоковым разбором `xml.parsers.expat` всех частей архива (R-25, I-1б: каждая некаталожная запись в любом каталоге, без отбора по имени или началу содержимого; ошибка разбора части не `*.xml` лишь заканчивает её скан; элемент с локальным именем `mergeCell` в любом пространстве имён; только атрибут `ref` без префикса; ссылки на символы раскрываются; площадь — по строке `ref`, без перебора ячеек; отказ сразу при превышении); для любого формата — после чтения, по диапазонам, которые индекс листа раскладывает по ячейкам (площадь не больше `grid.MAX_INDEXED_AREA`; merged на весь столбец не считается); число остальных (крупных) диапазонов листа — не больше `LARGE_MERGED_COUNT_LIMIT` (1000). Книга не открывается (в том числе битый XML части `*.xml`, `ref` объединения, который openpyxl не разберёт) → `UNREADABLE` (лог WARNING); превышение `PARSE_TIMEOUT_SECONDS` (по умолчанию 120, проверка между листами, в циклах по всему листу — поиск заголовка таблицы, дополнительные колонки, строки, стратегии иерархии; чтение книги библиотекой таймаут не прерывает) → `TIMEOUT`; непредвиденное исключение → `RECOGNITION_FAILED` с одной строкой причины, стек — в лог уровня ERROR. Не ограничены (остаточный риск, решение платформы — лимит памяти контейнера, `defusedxml`): объём самих ячеек, `sharedStrings` и стилей в пределах лимита распакованного zip, XML-бомбы entity expansion (кроме защиты самого expat ≥ 2.4). Любой файл `fixtures/osv/**` SHALL разбираться быстрее 5 с. #### Scenario: XLSB - **WHEN** разбирается синтетический xlsb той же ОСВ, что `s02` (`s13`, собран тестовым писателем BIFF12, а не Excel — O-7) - **THEN** результат совпадает с xlsx-оригиналом во всём, кроме `locator` и предупреждения документа `OSV_XLSB_NO_OUTLINE` («xlsb: outline level недоступен», `info`); `hierarchy=indent` #### Scenario: Расширение не совпадает с содержимым - **WHEN** запрос говорит `XLSX`, а байты — BIFF8 - **THEN** файл прочитан как xls, в `metrics.warnings` — `FILE_TYPE_MISMATCH` (`info`), статус по содержимому #### Scenario: xls без кодовой страницы - **WHEN** разбирается xls без записи CODEPAGE (синтетика, `codepage=None`) - **THEN** строки прочитаны как cp1251, в `metrics.warnings` — предупреждение файла `XLS_NO_CODEPAGE` (`info`, без `locator`; код пакета, `OSV_`-кода для него нет); статус по содержимому #### Scenario: Превышен лимит - **WHEN** передан файл в 51 МиБ - **THEN** `error UNSUPPORTED_FORMAT`, книга не открывается #### Scenario: Бомба объединённых ячеек - **WHEN** передан xlsx в несколько килобайт с `` (5,2 млн ячеек) - **THEN** `error UNSUPPORTED_FORMAT` («лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается #### Scenario: Обходы скана объединённых ячеек - **WHEN** тот же диапазон спрятан: лист вне `xl/worksheets/` или с расширением не `.xml` (цель связи в `workbook.xml.rels`), `ref` записан ссылками на символы (`A1:Z200000`, `A1:…`), перед `ref` стоит атрибут с префиксом (`a:ref="A1"`), в значении другого атрибута есть `>`, элемент записан с префиксом (`x:mergeCell`) - **THEN** `error UNSUPPORTED_FORMAT` («лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается; тот же вариант с малым диапазоном читается, и объединение видно в листе #### Scenario: Объединение на весь столбец в xls - **WHEN** в книге xls есть законный merged-диапазон на весь столбец (больше `grid.MAX_INDEXED_AREA`) - **THEN** книга читается: после-чтения проверка его не считает (индекс листа его по ячейкам не раскладывает) #### Scenario: Распакованный zip больше лимита - **WHEN** zip книги распаковывается больше чем в 10 × `PARSE_MAX_BYTES` - **THEN** `error UNSUPPORTED_FORMAT` («распакованный размер … больше лимита»), книга не открывается ### Requirement: Листы ОСВ находятся по содержимому Парсер SHALL считать листом ОСВ любой лист, где ячейка в первых 30 строках листа (`SHEET_MARKER_ROWS`: шапка ведомости всегда над таблицей) матчит признак ОСВ из `rules/osv_headers.yaml` (без учёта регистра, «ё/е», переносов строк и дефисов). Лист с признаками формы ББ/ОФР или её продолжения уходит в конвейер форм (спека `statement-parsing`); прочие листы SHALL пропускаться с предупреждением файла `SHEET_OTHER` (`severity = info`, `locator = sheet:<имя>`) в `metrics.warnings`. Книга без единого листа ОСВ и без форм → `status = error`, `error.code = INVALID_DOCUMENT` («в книге нет листов ОСВ или форм»). Несколько листов ОСВ → несколько `documents` с `locator = sheet:<имя>`. Имя листа MUST NOT использоваться для поиска. #### Scenario: Один лист — одна ОСВ (все реальные фикстуры) - **WHEN** разбирается любой файл из `fixtures/osv/**` - **THEN** `documents` содержит ровно один `osvDocument` с `locator = "sheet:Лист_1"`, статус `done` #### Scenario: Два листа ОСВ и посторонний лист - **WHEN** разбирается синтетический файл с листами «ОСВ 60», «Справка», «ОСВ 62» - **THEN** `documents` содержит два документа в порядке листов, в `metrics.warnings` есть `SHEET_OTHER` с `locator = "sheet:Справка"` #### Scenario: Нет ОСВ - **WHEN** разбирается книга с одним листом произвольной таблицы - **THEN** `status = error`, `error.code = INVALID_DOCUMENT`, сообщение называет число просмотренных листов ### Requirement: Иерархия строк — каскад стратегий Парсер SHALL определять `level` строк данных первой подходящей стратегией из списка: outline level Excel; отступ ячейки или ведущие пробелы; шаблон кода счёта и жирность; равенство «сумма детей = родитель». Стратегия подходит, если даёт дерево, где у каждой строки есть предок с меньшим уровнем (кроме уровня 0). Строка «Итого» стратегиям MUST NOT передаваться: она всегда имеет уровень 0 и не имеет родителя (R-14). Если строк данных (кроме «Итого») меньше двух (пустая ОСВ или одна строка) — стратегия `trivial`: дерево из корней (R-13). Выбранная стратегия SHALL быть записана в `warnings[]` документа листа предупреждением `OSV_HIERARCHY_BY_<СТРАТЕГИЯ>` (`severity = info`); в `metrics.warnings` строк `sheet=<имя> hierarchy=…` нет. `parent_row_number` SHALL указывать на ближайшую предыдущую строку с меньшим уровнем. #### Scenario: Outline level (реальные файлы) - **WHEN** разбирается `rb/2024-FY/ОСВ_66 за 2024.xls` (и любой другой реальный файл со строками данных) - **THEN** предупреждение документа `OSV_HIERARCHY_BY_OUTLINE`, строки `66` (0) → `66.01` (1) → контрагент (2); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» `parent_row_number` — строка `66.03` #### Scenario: Без outline level - **WHEN** разбирается синтетическая ОСВ без группировок Excel, с отступами ячеек (`s02`) - **THEN** `OSV_HIERARCHY_BY_INDENT`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по кодам счетов и жирности (`s03`) #### Scenario: Без outline и отступов - **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`) - **THEN** `OSV_HIERARCHY_BY_ACCOUNT_PATTERN`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт #### Scenario: Пустая ОСВ — тривиальное дерево - **WHEN** в ОСВ нет строк данных кроме «Итого» - **THEN** `OSV_HIERARCHY_BY_TRIVIAL`, «Итого» — уровень 0 без родителя ### Requirement: Золотые снимки и property-тесты Для каждого файла `fixtures/osv/**` SHALL существовать снимок `fixtures/golden/expected_osv_parse/<зеркальный путь>.json` (= файл результата без `job_id`, `file_id`, `service.version`, `service.statements_version`, `metrics.duration_ms`); тест сравнивает разбор со снимком, обновление — только явной командой (`make golden-update`) с просмотром diff. Property-тест по всем файлам SHALL проверять инварианты независимо от снимков (статус ≠ `error`, контрольные суммы, корень = счёт шапки, период распознан). Без каталога fixtures оба теста пропускаются с причиной (`ARM_KA_FIXTURES`). #### Scenario: Регрессия - **WHEN** правка парсера меняет `level` одной строки в любом реальном файле - **THEN** golden-тест падает с diff по этому файлу #### Scenario: Новый файл без снимка - **WHEN** в `fixtures/osv/` добавлен файл без снимка - **THEN** property-тест его разбирает, golden-тест сообщает «нет снимка: выполните make golden-update»