proposal, design (D-1…D-10), спеки osv-parsing (новая) и tabular-recognition-service (MODIFIED «Парсер — заменяемый интерфейс»), tasks (13 задач). Решение владельца 05.10.2026: подсрез 2a — только парсер ОСВ. Issue: RSHB/arm-tabular-parser#5 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
20 KiB
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или1по факту файла, предупреждение «нет строк данных»
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»