Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
208 lines
20 KiB
Markdown
208 lines
20 KiB
Markdown
# 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»
|