diff --git a/openspec/changes/slice-2a-osv-parser/.openspec.yaml b/openspec/changes/archive/2026-10-05-slice-2a-osv-parser/.openspec.yaml similarity index 100% rename from openspec/changes/slice-2a-osv-parser/.openspec.yaml rename to openspec/changes/archive/2026-10-05-slice-2a-osv-parser/.openspec.yaml diff --git a/openspec/changes/slice-2a-osv-parser/design.md b/openspec/changes/archive/2026-10-05-slice-2a-osv-parser/design.md similarity index 100% rename from openspec/changes/slice-2a-osv-parser/design.md rename to openspec/changes/archive/2026-10-05-slice-2a-osv-parser/design.md diff --git a/openspec/changes/slice-2a-osv-parser/proposal.md b/openspec/changes/archive/2026-10-05-slice-2a-osv-parser/proposal.md similarity index 100% rename from openspec/changes/slice-2a-osv-parser/proposal.md rename to openspec/changes/archive/2026-10-05-slice-2a-osv-parser/proposal.md diff --git a/openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md b/openspec/changes/archive/2026-10-05-slice-2a-osv-parser/specs/osv-parsing/spec.md similarity index 100% rename from openspec/changes/slice-2a-osv-parser/specs/osv-parsing/spec.md rename to openspec/changes/archive/2026-10-05-slice-2a-osv-parser/specs/osv-parsing/spec.md diff --git a/openspec/changes/slice-2a-osv-parser/specs/tabular-recognition-service/spec.md b/openspec/changes/archive/2026-10-05-slice-2a-osv-parser/specs/tabular-recognition-service/spec.md similarity index 100% rename from openspec/changes/slice-2a-osv-parser/specs/tabular-recognition-service/spec.md rename to openspec/changes/archive/2026-10-05-slice-2a-osv-parser/specs/tabular-recognition-service/spec.md diff --git a/openspec/changes/slice-2a-osv-parser/tasks.md b/openspec/changes/archive/2026-10-05-slice-2a-osv-parser/tasks.md similarity index 100% rename from openspec/changes/slice-2a-osv-parser/tasks.md rename to openspec/changes/archive/2026-10-05-slice-2a-osv-parser/tasks.md diff --git a/openspec/specs/osv-parsing/spec.md b/openspec/specs/osv-parsing/spec.md new file mode 100644 index 0000000..32b3a4c --- /dev/null +++ b/openspec/specs/osv-parsing/spec.md @@ -0,0 +1,304 @@ +# osv-parsing Specification + +## Purpose +Разбор оборотно-сальдовых ведомостей из XLS/XLSX/XLSB по `05-domain-rules.md` §1: структура определяется по содержимому, +результат — `osvDocument` контракта v1.1, достоверность подтверждается контрольными суммами, словари — данные. + +## Requirements + +### Requirement: Листы ОСВ находятся по содержимому +Парсер SHALL считать листом ОСВ любой лист, где ячейка в первых 30 строках листа (`SHEET_MARKER_ROWS`: шапка ведомости +всегда над таблицей) матчит признак ОСВ из `rules/osv_headers.yaml` (без учёта регистра, «ё/е», переносов строк и +дефисов). Листы без признаков ОСВ SHALL пропускаться с предупреждением +`sheet=<имя> skipped: нет признаков ОСВ` в `metrics.warnings`; лист с признаками ББ/ОФР (§12 п. 1) — с предупреждением +`sheet=<имя> skipped: statement (slice 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 подряд идущих строки, в которых (с учётом объединённых ячеек) встречаются +группы «сальдо на начало», «обороты за период», «сальдо на конец» по синонимам словаря, и под каждой группой подзаголовки +«Дебет»/«Кредит» по синонимам. Группы «обороты» и «сальдо на конец» обязательны: строка, где их нет, заголовком не +считается (R-12). Допустимы: отсутствие группы «начало» (сальдо на начало `null`), одна колонка в группе без +Дебет/Кредит (свёрнутое сальдо: положительное — дебет, отрицательное — кредит, предупреждение), дополнительные колонки +(«Количество», «Валюта», «ИНН», «КПП», «Код»). Колонка текста строки — первая непустая слева от сумм по строкам данных; +колонка ИНН — по заголовку `ИНН` или колонка, где ≥ 80 % непустых значений — ровно 10 или 12 цифр (число из 9/11 цифр +при этом выборе MUST NOT дополняться нулём: иначе числовая колонка КПП сошла бы за колонку ИНН, R-24). Правая граница таблицы — +последняя колонка «Кредит»; всё правее 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). Строка «Итого» стратегиям MUST NOT передаваться: +она всегда имеет уровень 0 и не имеет родителя (R-14). Если строк данных (кроме «Итого») меньше двух (пустая ОСВ или одна +строка) — стратегия `trivial`: дерево из корней (R-13). Выбранная стратегия SHALL быть записана в `metrics.warnings` как +`sheet=<имя> 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, с отступами ячеек (`s02`) +- **THEN** `hierarchy=indent`, дерево 0/1/2 и суммы совпадают с деревом той же ведомости, где уровни видны только по + кодам счетов и жирности (`s03`) + +#### Scenario: Без outline и отступов +- **WHEN** разбирается синтетическая ОСВ, где уровни видны только по кодам счетов и жирности (`s03`) +- **THEN** `hierarchy=account_pattern`, инварианты §1.4 выполнены, «Итого» не вложена в последний счёт + +#### Scenario: Пустая ОСВ — тривиальное дерево +- **WHEN** в ОСВ нет строк данных кроме «Итого» +- **THEN** `hierarchy=trivial`, «Итого» — уровень 0 без родителя + +### Requirement: Классификация строк +Каждая строка данных SHALL получить `group_type`: ACCOUNT — текст матчит `ACCOUNT_RE` (регистр учитывается, IGNORECASE +нет): две-три цифры и до двух сегментов субсчёта через точку, где сегмент — `\d{1,2}` **или 1–3 заглавные буквы** +кириллицы/латиницы («76.АВ», «76.ВА», «76.ОТ», R-16), далее опционально запятая и название: `^\d{2,3}(\.(\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2}(,\s*.*)?$` +с флагами `re.ASCII | re.DOTALL` (после запятой допустим любой пробел или его отсутствие, R-18; цифры — только 0–9; +название после запятой может занимать несколько строк ячейки — M-1); `account_code_raw` — код как в файле, без названия после +запятой. Не-счёт — группировка из `group_schema` по позиции (число не-счётных предков после последнего ACCOUNT + 1), при +выходе за схему — OTHER; последняя строка уровня 0 с текстом из словаря «Итого» — TOTAL. Для COUNTERPARTY +`counterparty_name = row_value`, `counterparty_inn` — **только** из колонки ИНН: цифры текстовой ячейки колонки («ИНН +352819549855» → «352819549855», R-15), результат только при 10 или 12 цифрах, иначе `null`; число из 9/11 цифр +(ИНН регионов 01–09 с потерянным ведущим нулём) при извлечении дополняется нулём слева (R-24); ИНН внутри текста строки +(«Полиграф, 3528315710») MUST NOT извлекаться (R-17). Строки «Вал.», «Кол.», трёхбуквенного кода валюты SHALL получать +`group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST NOT занимать позицию в `group_schema` и MUST NOT наследовать +`counterparty_name`/`counterparty_inn` (`account_code_raw` наследуется от ветки, R-19); их суммы остаются в шести полях. +Остальные строки получают `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** в ОСВ 76 строка «76.АВ» (реальные файлы РБ и Счастливочной) либо «60.АВ, …» (синтетика) +- **THEN** `group_type = ACCOUNT`, строка наследуется детьми как счёт ветки; «76.ав» (строчные) счётом не считается + +#### Scenario: Строки Вал./Кол. +- **WHEN** в синтетической ОСВ под контрагентом строки «USD» и «Кол.» +- **THEN** `group_type = OTHER`, `data_type = CURRENCY` и `QUANTITY`, `counterparty_name`/`counterparty_inn` равны `null`, + `account_code_raw` совпадает с родителем, строки исключены из проверки «сумма детей = родитель», статус `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), причём **обороты** сверяются по колонкам, а **сальдо** (начало, конец) — по колонкам либо, если у +родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто «дебет − кредит» (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}`. Любое нарушение → `status = needs_check` всего результата и запись +причины в `metrics.warnings`; парсер MUST 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` — текст с номерами строк + +#### Scenario: Сальдо родителя свёрнуто нетто +- **WHEN** у родителя сальдо показано одной стороной (нетто), а у детей — обеими сторонами, и нетто детей равно нетто родителя +- **THEN** нарушения «сумма детей = родитель» по сальдо нет; обороты при этом сверяются по колонкам, а неверное нетто + или расхождение оборотов — нарушение + +#### Scenario: «Итого» не найдена +- **WHEN** в таблице нет строки с текстом из словаря «Итого» +- **THEN** `total_row_matches_root = false`, `status = needs_check`, в `warnings` — «строка «Итого» не найдена» + +#### 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 пусты), предупреждение + «нет строк данных» + +### Requirement: Форматы, лимиты, время +Парсер SHALL читать XLSX (openpyxl, полное чтение), XLS (xlrd, `formatting_info`), XLSB (pyxlsb; outline level недоступен — +каскад начинается с отступа). Несоответствие `file_type` сигнатуре файла → попытка по сигнатуре и предупреждение. +Файл больше `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` и предупреждения «xlsb: outline level + недоступен»; `hierarchy=indent` + +#### Scenario: Расширение не совпадает с содержимым +- **WHEN** запрос говорит `XLSX`, а байты — BIFF8 +- **THEN** файл прочитан как xls, в `warnings` есть запись о несоответствии, статус по содержимому + +#### 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: Словари — данные, не код +Признаки листов, синонимы групп колонок и подзаголовков, колонки ИНН, строки «Итого», маркеров шапки (`rules/osv_headers.yaml`: +`sheet_markers`, `statement_markers`, `groups`, `debit`, `credit`, `service_columns`, `total_row`, `header_markers`), +группировок (`rules/osv_groups.yaml`: `mapping`, `default`), периодов (`rules/periods.yaml`: `rules`, `year`, `end`), +типов данных и кодов валют (`rules/data_types.yaml`: `header`, `row`, `currency_codes`, `currency_aliases`) +SHALL загружаться из YAML и валидироваться pydantic-моделью при старте (ошибка словаря — падение старта с указанием файла и +записи). В `src/parser/` (кроме `grid.py` и `readers/`) MUST NOT быть строковых литералов букв колонок, адресов ячеек, +имён листов и сравнений с номерами строк — проверяется тестом по AST. Каждая запись распознавания словаря (паттерн) SHALL +срабатывать хотя бы на одной фикстуре или синтетике (тест покрытия словарей, R-22); перечень кодов валют +`currency_codes` (ISO 4217) — список значений, а не синонимы, и в покрытие не входит. + +#### Scenario: Новый синоним без релиза кода +- **WHEN** в `osv_headers.yaml` добавлен синоним «Нач. остаток» и дана синтетическая ОСВ с таким заголовком +- **THEN** разбор проходит без изменений в `src/`; без этого синонима заголовок «Нач. остаток» не распознаётся + (колонки начала уходят в `extra`, результат `needs_check`) + +#### Scenario: Литерал координаты +- **WHEN** в `detect.py` появляется строка `"E9"` +- **THEN** тест-линтер падает с указанием файла и строки + +#### Scenario: Мёртвая запись словаря +- **WHEN** в словарь добавлена запись, не срабатывающая ни на одном реальном или синтетическом файле +- **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» diff --git a/openspec/specs/tabular-recognition-service/spec.md b/openspec/specs/tabular-recognition-service/spec.md index c0957d2..e2d551c 100644 --- a/openspec/specs/tabular-recognition-service/spec.md +++ b/openspec/specs/tabular-recognition-service/spec.md @@ -2,8 +2,8 @@ ## Purpose Stateless-сервис разбора табличных файлов (XLS/XLSX/XLSB): принимает задания из NATS без JetStream, подтверждает их, -читает исходник из S3, отдаёт результат в S3 и конверт в NATS по контракту v1.0, не имея доступа к БД и фронта. -В Срезе 0 разбор заменён заглушкой. +читает исходник из S3, отдаёт результат в S3 и конверт в NATS по контракту v1.1, не имея доступа к БД и фронта. +С Среза 2a реализация по умолчанию разбирает ОСВ (спека `osv-parsing`); заглушка `NullParser` — только для тестов воркера. ## Requirements @@ -38,10 +38,17 @@ Stateless-сервис разбора табличных файлов (XLS/XLSX/ сервис SHALL повторить её до трёх раз с паузой, затем залогировать ошибку. #### Scenario: Запрос с заглушкой парсера (Срез 0) -- **WHEN** запрос подтверждён и объект существует в `uploads/` +- **WHEN** воркеру явно передана заглушка `NullParser` (только тесты воркера; реализация по умолчанию — реальный парсер), + запрос подтверждён и объект существует в `uploads/` - **THEN** в `recognition.result` появляется конверт `status = error`, `error.code = NOT_IMPLEMENTED`, `job_id`/`file_id` из запроса, `metrics.duration_ms ≥ 0`; файл результата может отсутствовать +#### Scenario: Запрос с реальным парсером +- **WHEN** запрос подтверждён и объект в `uploads/` существует, но не является книгой Excel (сигнатура не XLS/XLSX/XLSB) +- **THEN** в `recognition.result` появляется конверт `status = error`, `error.code = UNSUPPORTED_FORMAT` (повреждённая + книга — `UNREADABLE`), `job_id`/`file_id` из запроса, `metrics.duration_ms ≥ 0`; файл результата может отсутствовать; + корректная ОСВ в том же потоке даёт `done` (см. «Парсер — заменяемый интерфейс») + #### Scenario: Повтор публикации - **WHEN** первая публикация конверта результата завершается ошибкой соединения, вторая — успешно - **THEN** конверт опубликован один раз с тем же `Nats-Msg-Id`, обработка не повторяется @@ -79,13 +86,22 @@ Stateless-сервис разбора табличных файлов (XLS/XLSX/ ### Requirement: Парсер — заменяемый интерфейс Разбор файла SHALL выполняться через единый интерфейс «байты + тип файла → документы/метрики/ошибка», не знающий о -NATS и S3; воркер MUST NOT зависеть от конкретной реализации. В Срезе 0 реализация SHALL возвращать `NOT_IMPLEMENTED` -для всех типов; каталог `api/rules/` SHALL существовать с README о назначении словарей. +NATS и S3; воркер MUST NOT зависеть от конкретной реализации. Реализация по умолчанию (`get_parser()`) SHALL разбирать +ОСВ по спеке `osv-parsing` и писать файл результата с `schema_version = "1.1"`; листы с признаками ББ/ОФР SHALL +пропускаться с предупреждением до подсреза 2b. Заглушка `NullParser` (`error NOT_IMPLEMENTED`) SHALL использоваться только +в тестах воркера и MUST NOT быть реализацией по умолчанию. Реализация по умолчанию SHALL создаваться один раз при старте +приложения (словари читаются и проверяются там же). Каталог `api/rules/` SHALL содержать словари ОСВ (`osv_headers`, +`osv_groups`, `periods`, `data_types`) и README. #### Scenario: Подмена реализации в тесте - **WHEN** в тесте воркеру передан парсер, возвращающий один `osvDocument` - **THEN** файл результата с этим документом записан в S3, конверт имеет `status = done` и `result_s3` на этот файл +#### Scenario: Реальная ОСВ через воркер +- **WHEN** в интеграционном тесте в S3 лежит `rb/2024-FY/ОСВ_66 за 2024.xls` и опубликован запрос `file_type = XLS` +- **THEN** после ack публикуется конверт `done` с `result_schema_version = "1.1"`, файл результата проходит схему v1.1 и + содержит один `osvDocument` с `account_code_raw = "66"` + ### Requirement: Копия контракта идентична источнику `api/contracts/*.schema.json` SHALL быть байт в байт равны файлам `../docs/contracts/`; pydantic-модели SHALL валидировать все `../docs/contracts/examples/*.json` и порождать JSON, проходящий схему. Проверка входит в `make check`.