Andrey Epifancev 4d30718689 docs: дельты OpenSpec, design.md, README и CLAUDE.md под контракт 2.0 (#54)
I-3: дельты osv-parsing (листы ОСВ, иерархия, golden, XLS_NO_CODEPAGE) и
перечень словарей api/rules; пункт об архивации (Purpose спек) в tasks.md.
I-4: README, CLAUDE.md и docstring hierarchy.py совпадают с кодом, пример
вывода CLI переснят. M-6: tasks.md, proposal.md, design.md с решениями P4/R.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
2026-10-18 22:08:30 +04:00

23 KiB
Raw Blame History

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 в несколько килобайт с <mergeCell ref="A1:Z200000"/> (5,2 млн ячеек)
  • THEN error UNSUPPORTED_FORMAT («лимит объединённых ячеек») быстрее 1 с, openpyxl не вызывается

Scenario: Обходы скана объединённых ячеек

  • WHEN тот же диапазон спрятан: лист вне xl/worksheets/ или с расширением не .xml (цель связи в workbook.xml.rels), ref записан ссылками на символы (A1:Z&#50;00000, &#x41;1:…), перед 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»