Andrey Epifancev 6b37a88f26 openspec: уточнения D-4 и сценария пустой ОСВ
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 11:42:51 +04:00

20 KiB
Raw Blame History

Spec Delta: osv-parsing

Purpose

Разбор оборотно-сальдовых ведомостей из XLS/XLSX/XLSB по 05-domain-rules.md §1: структура определяется по содержимому, результат — osvDocument контракта v1.1, достоверность подтверждается контрольными суммами, словари — данные.

ADDED Requirements

Requirement: Листы ОСВ находятся по содержимому

Парсер SHALL считать листом ОСВ любой лист, где хотя бы одна ячейка матчит признак ОСВ из rules/osv_headers.yaml (без учёта регистра, «ё/е», переносов строк и дефисов). Листы без признаков ОСВ SHALL пропускаться с предупреждением в metrics.warnings; лист с признаками ББ/ОФР (§12 п. 1) — с предупреждением о подсрезе 2b. Книга без единого листа ОСВ → 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 содержит два документа в порядке листов, в warnings есть запись о пропуске листа «Справка»

Scenario: Нет ОСВ

  • WHEN разбирается книга с одним листом произвольной таблицы
  • THEN status = error, error.code = INVALID_DOCUMENT, сообщение называет число просмотренных листов

Requirement: Заголовок таблицы и колонки сумм определяются по тексту

Парсер SHALL найти блок заголовка таблицы: 1–4 подряд идущих строки, в которых (с учётом объединённых ячеек) встречаются группы «сальдо на начало», «обороты за период», «сальдо на конец» по синонимам словаря, и под каждой группой подзаголовки «Дебет»/«Кредит» по синонимам. Допустимы: отсутствие группы «начало» (сальдо на начало null), одна колонка в группе без Дебет/Кредит (свёрнутое сальдо: положительное — дебет, отрицательное — кредит, предупреждение), дополнительные колонки («Количество», «Валюта», «ИНН», «КПП», «Код»). Колонка текста строки — первая непустая слева от сумм по строкам данных; колонка ИНН — по заголовку ИНН или колонка, где ≥ 80 % непустых значений — 10 или 12 цифр. Правая граница таблицы — последняя колонка «Кредит»; всё правее MUST NOT попадать в суммы. Колонки внутри таблицы, не сопоставленные с шестью суммами и ИНН, SHALL попадать в rows[].extra по заголовку. Заголовок не найден или найдено меньше двух групп → status = error, error.code = INVALID_FORMAT с названием ненайденной группы. В columns SHALL быть записана диагностика (буква колонки) для каждой из шести сумм и ИНН.

Scenario: Плавающие колонки двух организаций

  • WHEN разбираются rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx и schastl/2024-Q3/…62 за 3 квартал 2024 г..xls
  • THEN columns первого содержит opening_debit = "E", closing_credit = "Q", второго — opening_debit = "G", closing_credit = "S", inn = "E"; суммы корневой строки совпадают со строкой «Итого»

Scenario: Мусор правее таблицы

  • WHEN разбирается schastl/2024-Q3/…62 за 3 квартал 2024 г..xls (числа в колонках T/U)
  • THEN эти значения не входят ни в одну из шести сумм, инварианты §1.4 выполнены

Scenario: ОСВ без сальдо на начало и со свёрнутым сальдо

  • WHEN разбирается синтетическая ОСВ с группами только «Обороты» и «Сальдо на конец», где у «конца» одна колонка
  • THEN opening_* = null у всех строк, отрицательное свёрнутое сальдо попадает в closing_credit, в warnings есть запись о свёрнутом сальдо, статус done

Scenario: Неизвестная колонка внутри таблицы

  • WHEN в синтетической ОСВ между «ИНН» и суммами есть колонка «КПП»
  • THEN у строк контрагентов extra["КПП"] содержит значение, суммы не сдвинуты

Requirement: Шапка документа и схема группировок

Парсер SHALL извлечь из области выше заголовка таблицы: organization_name (первая непустая текстовая строка, trim), account_code_raw (после «по счету»/«по счёту», как в файле: «60», «58.03»), period_text (после «за» до «г.»/конца), data_type («Выводимые данные: БУ/НУ/сумма»), currency («Валюта …: RUB/руб.» → RUB), header_variant (1 — группировки строками первой колонки блока заголовка; 2 — перечисление «субконто: …»). group_schema SHALL начинаться с ACCOUNT и далее перечислять группировки в порядке вложенности по словарю rules/osv_groups.yaml; ячейка ИНН в строке группировки → has_inn = true. period_quarter/is_cumulative SHALL вычисляться только по тексту по rules/periods.yaml (§1.5, ASSUMPTION(OQ-02)), period_year — четыре цифры года. Отсутствие любого поля шапки, кроме обязательных по контракту, — null и предупреждение; отсутствие organization_name/period_text — пустая строка и предупреждение (контракт требует строку).

Scenario: Вариант 1 с тремя строками группировок

  • WHEN разбирается schastl/2024-Q3/…62 за 3 квартал 2024 г..xls
  • THEN group_schema = [ACCOUNT, DEPARTMENT, COUNTERPARTY(has_inn=true)], header_variant = 1, account_code_raw = "62", period_text = "3 квартал 2024 г.", period_quarter = "Q3", is_cumulative = false, data_type = "BU", currency = "RUB"

Scenario: ОСВ по субсчёту и годовой период

  • WHEN разбираются rb/2024-Q3/сч 583_3 кв 2024.xlsx и rb/2024-FY/ОСВ_66 за 2024.xls
  • THEN у первого account_code_raw = "58.03"; у второго period_quarter = "Q4", is_cumulative = true

Scenario: Вариант 2

  • WHEN разбирается синтетическая ОСВ с шапкой «Период: 1 квартал 2026 г.», «Детализация по субсчетам, субконто: Контрагенты, Договоры», заголовком «Субконто»
  • THEN header_variant = 2, group_schema = [ACCOUNT, COUNTERPARTY, CONTRACT], period_quarter = "Q1"

Requirement: Иерархия строк — каскад стратегий

Парсер SHALL определять level строк данных первой подходящей стратегией из списка: outline level Excel; отступ ячейки или ведущие пробелы; шаблон кода счёта и жирность; равенство «сумма детей = родитель». Стратегия подходит, если даёт дерево, где у каждой строки есть предок с меньшим уровнем (кроме уровня 0) и строка «Итого» имеет уровень 0. Выбранная стратегия SHALL быть записана в metrics.warnings как hierarchy=<имя>. parent_row_number SHALL указывать на ближайшую предыдущую строку с меньшим уровнем.

Scenario: Outline level (реальные файлы)

  • WHEN разбирается rb/2024-FY/ОСВ_66 за 2024.xls
  • THEN hierarchy=outline, строки 66 (0) → 66.01 (1) → контрагент (2); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» parent_row_number — строка 66.03

Scenario: Без outline level

  • WHEN разбирается синтетическая копия той же ОСВ без группировок Excel, с отступами ячеек
  • THEN hierarchy=indent, дерево и суммы совпадают с оригиналом

Scenario: Без outline и отступов

  • WHEN разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности
  • THEN hierarchy=account_pattern, инварианты §1.4 выполнены

Requirement: Классификация строк

Каждая строка данных SHALL получить group_type: ACCOUNT — текст матчит ^\d{2,3}(\.\d{1,2}){0,2}(,\s.*)?$ (account_code_raw — код как в файле, без названия после запятой); не-счёт — группировка из group_schema по позиции (число не-счётных предков после последнего ACCOUNT + 1), при выходе за схему — OTHER; последняя строка уровня 0 с текстом из словаря «Итого» — TOTAL. Для COUNTERPARTY counterparty_name = row_value, counterparty_inn — только цифры длиной 10 или 12 из колонки ИНН, иначе null. Строки «Вал.», «Кол.», трёхбуквенного кода валюты SHALL получать data_type CURRENCY/QUANTITY (суммы остаются в шести полях), остальные — data_type шапки. Все строки, включая TOTAL, SHALL присутствовать в rows (ASSUMPTION(OQ-01): хранить ли «Итого», решает ядро).

Scenario: Подразделение, контрагент с ИНН, физлицо

  • WHEN разбирается schastl/2024-Q3/…62 за 3 квартал 2024 г..xls
  • THEN «БП Аренда» → DEPARTMENT; «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» → COUNTERPARTY с counterparty_inn из 10 цифр; «Ведомость в счет з/платы» → COUNTERPARTY с counterparty_inn = null

Scenario: Название субсчёта в строке счёта

  • WHEN в синтетической ОСВ строка «60.01, Расчеты с поставщиками»
  • THEN group_type = ACCOUNT, account_code_raw = "60.01", row_value — исходный текст

Scenario: Строки Вал./Кол.

  • WHEN в синтетической ОСВ под контрагентом строки «USD» и «Кол.»
  • THEN data_type = CURRENCY и QUANTITY, они исключены из проверки «сумма детей = родитель», статус done

Requirement: Числа — Decimal, пусто — null

Суммы SHALL приводиться к Decimal с двумя знаками: числовые ячейки — через строковое представление; текстовые — после удаления пробелов (включая неразрывные и узкие), замены запятой на точку, (x) → -x; «-», «—», «–», пустая ячейка → null (не 0). Нечисловой текст в колонке сумм → null и предупреждение с row_number. float в результате MUST NOT использоваться; в JSON суммы — строки ^-?\d+(\.\d{1,2})?$.

Scenario: Текстовые числа

  • WHEN в синтетической ОСВ суммы записаны как «1 234 567,89», «(1 234,00)», «—»
  • THEN получены 1234567.89, -1234.00, null

Requirement: Контрольные суммы определяют статус

Для каждого документа парсер SHALL проверить: по каждой строке opening_debit − opening_credit + turnover_debit − turnover_credit = closing_debit − closing_credit (±0.01, null = 0); по каждому родителю суммы = Σ детей с тем же data_type по шести колонкам (±0.01); строка TOTAL = корневая строка ACCOUNT. Результат — checks{total_row_matches_root, balance_identity_violations, parent_sum_violations}. Любое нарушение → status = needs_check всего результата и запись причины в metrics.warnings; парсер MUST NOT подгонять структуру под сходимость.

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 — текст с номерами строк

Scenario: Пустая ОСВ

  • WHEN разбирается schastl/2025-Q2/…66 за 2 квартал 2025 г..xlsx (заголовок и сразу «Итого»)
  • THEN status = done, rows содержит только строку TOTAL, metrics.rows = 0 (строки данных без TOTAL), checks.total_row_matches_root = true (корня нет, суммы TOTAL пусты), предупреждение «нет строк данных»

Requirement: Форматы, лимиты, время

Парсер SHALL читать XLSX (openpyxl, полное чтение), XLS (xlrd, formatting_info), XLSB (pyxlsb; outline level недоступен — каскад начинается с отступа). Несоответствие file_type сигнатуре файла → попытка по сигнатуре и предупреждение. Файл больше PARSE_MAX_BYTES (по умолчанию 50 МиБ) → error UNSUPPORTED_FORMAT с текстом про лимит; книга не открывается → UNREADABLE; превышение PARSE_TIMEOUT_SECONDS (по умолчанию 120, проверка между листами, стратегиями и блоками строк) → TIMEOUT; непредвиденное исключение → RECOGNITION_FAILED с одной строкой причины. Любой файл fixtures/osv/** SHALL разбираться быстрее 5 с.

Scenario: XLSB

  • WHEN разбирается синтетический xlsb той же ОСВ
  • THEN результат совпадает с xlsx-оригиналом во всём, кроме hierarchy=indent и locator

Scenario: Расширение не совпадает с содержимым

  • WHEN запрос говорит XLSX, а байты — BIFF8
  • THEN файл прочитан как xls, в warnings есть запись о несоответствии, статус по содержимому

Scenario: Превышен лимит

  • WHEN передан файл в 51 МиБ
  • THEN error UNSUPPORTED_FORMAT, книга не открывается

Requirement: Словари — данные, не код

Признаки листов, синонимы групп колонок и подзаголовков, служебных колонок, маркеров шапки (rules/osv_headers.yaml), группировок (rules/osv_groups.yaml), периодов (rules/periods.yaml), типов данных и кодов валют (rules/data_types.yaml) SHALL загружаться из YAML и валидироваться pydantic-моделью при старте (ошибка словаря — падение старта с указанием файла и записи). В src/parser/ (кроме grid.py и readers/) MUST NOT быть строковых литералов букв колонок, адресов ячеек, имён листов и сравнений с номерами строк — проверяется тестом по AST. Каждая запись словаря SHALL срабатывать хотя бы на одной фикстуре или синтетике (тест покрытия словарей).

Scenario: Новый синоним без релиза кода

  • WHEN в osv_headers.yaml добавлен синоним «Нач. остаток» и дана синтетическая ОСВ с таким заголовком
  • THEN разбор проходит без изменений в src/

Scenario: Литерал координаты

  • WHEN в detect.py появляется строка "E9"
  • THEN тест-линтер падает с указанием файла и строки

Requirement: Золотые снимки и property-тесты

Для каждого файла fixtures/osv/** SHALL существовать снимок fixtures/golden/expected_osv_parse/<зеркальный путь>.json (= файл результата без job_id, file_id, service.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»