Andrey Epifancev ef14747f49 fix(osv): счёт шапки «Счет Поставщика | 60*» и голые коды контрагентов (AURORA-1714, #71)
Метки «Счет Поставщика»/«Счет Клиента» добавлены в header_markers.account; код счёта читается из соседней ячейки строки,
«*» отбрасывается. При известном счёте шапки голый числовой код, не совпадающий с ним, не считается счётом.
Синтетика s45, тест на книге openpyxl, сценарий в спеке osv-parsing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:16:21 +04:00

597 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` (без учёта регистра, «ё/е», переносов строк и
дефисов). Лист с признаками формы ББ/ОФР или её продолжения уходит в конвейер форм (спека `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 найти блок заголовка таблицы: 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*» -> «60»; метка без значения пропускается), в том числе через двоеточие «по счету: 76.Н.1», как в файле: «60», «58.03»; код счёта шапки важнее первой строки-счёта таблицы — она используется, только если в шапке кода нет, OQ-134), `period_text` (после «за» до «г.»/конца),
`data_type` («Выводимые данные: БУ/НУ/сумма»), `currency` («Валюта …: RUB/руб.» → `RUB`), `header_variant` (1 — группировки
строками первой колонки блока заголовка; 2 — перечисление «субконто: …»). `group_schema` SHALL начинаться с ACCOUNT и далее
перечислять группировки в порядке вложенности по словарю `rules/osv_groups.yaml`; ячейка `ИНН` в строке группировки →
`has_inn = true`. Ячейки между строкой названия ведомости (с меткой «по счету») и строкой периода, если название выше периода, — **строка вида субконто** (плоская форма 1С: «Контрагенты», «Организации», «Материалы»): она MUST NOT становиться `organization_name`; если схема варианта 1 состоит из одной ACCOUNT («Субконто»), элементы этой строки (через запятую), которые словарь `rules/osv_groups.yaml` узнаёт явной записью `mapping` (не `default`), SHALL добавляться в схему после ACCOUNT; неузнанные элементы в схему MUST NOT попадать. Элемент вида `<группировка>.ИНН` (суффикс после последней точки — по словарю `service_columns.inn`,
префикс — имя группировки `COUNTERPARTY` этой же шапки; вариант 2 — элемент списка «субконто: …», вариант 1 — строка блока
заголовка) MUST NOT попадать в `group_schema`: группировка-владелец получает `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"`
#### Scenario: Реквизит ИНН после группировки
- **WHEN** разбирается `osv-dkb/novopetrovskaya/2025-Q3/60_ 9мес.2025г.xls` (шапка «субконто: Контрагенты,
Контрагенты.ИНН»)
- **THEN** `group_schema = [ACCOUNT, COUNTERPARTY(has_inn=true)]`, подсказка «ИНН под наименованием»
#### Scenario: Реквизит ИНН перед группировкой
- **WHEN** в синтетической ОСВ шапка «субконто: Контрагенты.ИНН, Контрагенты»
- **THEN** `group_schema = [ACCOUNT, COUNTERPARTY(has_inn=true)]`, подсказка «ИНН над наименованием»
#### Scenario: Плоская форма — счёт с двоеточием, вид субконто, организация
- **WHEN** разбирается синтетическая ОСВ `s40` (шапка «Оборотно-сальдовая ведомость по счету: 76.Н.1», строка
«Контрагенты», «за 2025 г.», строка «ООО "Товарное хозяйство"», колонка «Субконто», сразу контрагенты)
- **THEN** `account_code_raw = "76.Н.1"` без предупреждения «код счёта … взят из первой строки-счёта»,
`organization_name = "ООО \"Товарное хозяйство\""`, `group_schema = [ACCOUNT(Субконто), COUNTERPARTY(Контрагенты)]`
#### Scenario: Организация из «План счетов»
- **WHEN** разбирается синтетическая ОСВ `s41` (шапка «…по счету: 60», строка «Организации», «за 2 Квартал 2026 г.
План счетов ООО Хреновская Нива»)
- **THEN** `organization_name = "ООО Хреновская Нива"`, `account_code_raw = "60"`,
`group_schema = [ACCOUNT(Субконто), COUNTERPARTY(Организации)]` (множественное «Организации» — синоним COUNTERPARTY в словаре; единственное «Организация» — собственная организация учёта, R-17)
#### Scenario: Счёт шапки в соседней ячейке от метки
- **WHEN** разбирается синтетическая ОСВ `s45` (выгрузка 1С «ОСВ по счёту 60 по контрагентам»: шапка «Счет Поставщика» | «60*»
в соседних ячейках, «Счет Клиента» без значения, слов «по счету» нет; в первой колонке голые числовые коды 441, 431, 199)
- **THEN** `account_code_raw = "60"`; голый числовой код, не совпадающий со счётом шапки, счётом не считается (это код
контрагента); строки — `COUNTERPARTY` по плану счетов, `done`
#### Scenario: Счёт шапки важнее строки таблицы
- **WHEN** разбирается синтетическая ОСВ `s42` (шапка «…по счету: 76», первая строка таблицы «76.2»)
- **THEN** `account_code_raw = "76"`
### Requirement: Иерархия строк — каскад стратегий
Парсер SHALL определять `level` строк данных первой подходящей стратегией из списка: outline level Excel; отступ ячейки
или ведущие пробелы; шаблон кода счёта и жирность; плоский список под счётом шапки (`header_account`); равенство «сумма детей = родитель». Стратегия `header_account` (все строки — уровень 0) применима, только если в шапке есть код счёта и среди строк таблицы нет ни одной строки-счёта (плоская форма 1С). Стратегия подходит, если даёт дерево,
где у каждой строки есть предок с меньшим уровнем (кроме уровня 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 без родителя
#### Scenario: Плоский список под счётом шапки
- **WHEN** разбирается синтетическая ОСВ `s40` либо `s41` (без outline, отступов и строк-счетов; сразу контрагенты)
- **THEN** вместо `INVALID_FORMAT` — `OSV_HIERARCHY_BY_HEADER_ACCOUNT`, все строки данных уровня 0 без родителя,
`account_code_raw` каждой строки равен коду счёта шапки
### Requirement: Классификация строк
Классификация SHALL выполняться над деревом, из которого уже убраны склеенные строки ИНН (требование «Строки ИНН рядом с
контрагентом»). Каждая строка данных 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, кроме **расчётных счетов**: если позиция 1 (первая не-счётная) выходит за схему, а счёт ветки — из списка `counterparty_accounts` `rules/osv_groups.yaml` (начальный состав 58, 60, 62, 66, 67, 75, 76, 80; запись `76` покрывает `76`, `76.2`, `76.Н.1`, но не `760`), строка SHALL получить COUNTERPARTY без имени группировки, а документ — предупреждение `OSV_ROWS` «вид субконто в шапке не назван: строки аналитики счёта <код> приняты за контрагентов по плану счетов — проверьте» (один раз на счёт); счёт ветки строк до первой строки-счёта — код счёта шапки; строка ИНН без пары — OTHER;
строка «Итого» и вторые итоговые строки («Итого развернутое») в итоговом блоке сразу перед ней или сразу за ней, включая строки-показатели итоговых строк, — TOTAL; строки-показатели входят в блок перед «Итого», только если выше них стоит вторая итоговая строка.
Для COUNTERPARTY `counterparty_name` SHALL быть текстом строки без ИНН в конце и разделителя перед ним (`row_value` —
исходный текст; OQ-100), а `counterparty_inn` — первым найденным из:
1. колонки ИНН: цифры текстовой ячейки («ИНН 352819549855» → «352819549855», R-15), склеенная «ИНН/КПП» (`inn_kpp_cell`)
→ ИНН; результат только при 10 или 12 цифрах, иначе `null`; число из 9/11 цифр (ИНН регионов 01–09 с потерянным
ведущим нулём) дополняется нулём слева (R-24);
2. строки ИНН, склеенной с этой строкой (требование «Строки ИНН рядом с контрагентом»);
3. конца текста названия: 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»
(`counterparty_inn_suffix`; «Полиграф, 3528315710», «ООО Альфа (ИНН 7701234567)»).
Контрольная сумма ИНН из колонки и из названия MUST NOT проверяться. Строки-показатели (требование «Строки-показатели
и колонка «Показатели»») SHALL получать `group_type = OTHER`, `data_type` по метке, MUST NOT занимать позицию в
`group_schema` и MUST NOT наследовать `counterparty_name`/`counterparty_inn` (`account_code_raw` наследуется от ветки
родителя, R-19); их суммы остаются в шести полях. Строка с меткой «БУ» в колонке «Показатели» получает `data_type = BU`.
Остальные строки получают `data_type` шапки, а если шапка вид данных не называет — `BU` и одно предупреждение `OSV_HEADER`
на лист «вид данных в шапке не указан — строки без метки приняты как БУ» (`header.data_type` остаётся `null`). Все строки, включая TOTAL, SHALL присутствовать в `rows`
(`ASSUMPTION(OQ-01)`: хранить ли «Итого», решает ядро); склеенные строки ИНН в `rows` MUST NOT попадать.
#### Scenario: Подразделение, контрагент с ИНН, физлицо
- **WHEN** разбирается `schastl/2024-Q3/…62 за 3 квартал 2024 г..xls`
- **THEN** «БП Аренда» → DEPARTMENT; «МАЛЕЧКИНСКАЯ ПТИЦЕФАБРИКА ООО» → COUNTERPARTY с `counterparty_inn` из 10 цифр;
«Ведомость в счет з/платы» → COUNTERPARTY с `counterparty_inn = null`
#### Scenario: ИНН в конце названия
- **WHEN** разбирается синтетическая ОСВ `s17` (ИНН в тексте названия с разными разделителями)
- **THEN** у контрагентов `counterparty_name` без ИНН, `counterparty_inn` — ИНН из текста, `row_value` — исходный текст
#### Scenario: «ИНН/КПП» в одной ячейке колонки ИНН
- **WHEN** разбирается синтетическая ОСВ `s19`
- **THEN** `counterparty_inn` — 10 цифр до «/»
#### 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`
#### Scenario: Шапка без вида данных
- **WHEN** разбирается синтетическая ОСВ `s36` (шапка без «Выводимые данные», колонки «Показатели» нет)
- **THEN** `header.data_type = null`, у всех строк данных и «Итого» `data_type = BU`, в `warnings` одно `OSV_HEADER` «вид
данных в шапке не указан — строки без метки приняты как БУ», статус `done`
#### Scenario: «Валюта <код>» — обычная строка
- **WHEN** в синтетической ОСВ `s35` под контрагентом строка «Валюта USD» с рублёвыми суммами
- **THEN** `group_type = OTHER`, `data_type` шапки, строка участвует в сверке «сумма детей = родитель»
#### Scenario: Контрагенты расчётного счёта без названия вида
- **WHEN** разбирается синтетическая ОСВ `s43` (лист как ОСВОКТ-24 «60 сч.»: колонка «Субконто», строка «60.01», под ней
контрагенты с отступом) либо `s41`
- **THEN** контрагенты — COUNTERPARTY без `group_name`, `counterparty_name` — текст строки; в `warnings[]` — `OSV_ROWS`
«вид субконто в шапке не назван…»; на нерасчётном счёте (синтетика `s44`, счёт 10) те же строки — OTHER без
предупреждения
### 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С показывает счёт нетто,
а субсчета и контрагентов — развёрнуто; остаточные риски перечислены ниже); строки-показатели (требование «Строки-показатели и колонка
«Показатели»») в суммирование не входят и сами родителями не проверяются; строка TOTAL = корневая строка ACCOUNT (первая строка уровня 0) либо сумма строк уровня 0; основная «Итого» — первая строка TOTAL без признака показателя, текст которой не «Итого развернутое» (`total_row_secondary`), в каком бы порядке они ни стояли; итоговая строка-показатель («Итого» с меткой «НУ», «Кол.») в эту сверку не входит. Если корневой ACCOUNT нет, а строки уровня 0 есть (плоский список), «Итого» SHALL сверяться с их суммой (те же допуски R-21); если строк данных нет (пустая ОСВ, 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 подгонять
структуру под сходимость.
Вторая итоговая строка (`total_row_secondary`, «Итого развернутое») SHALL NOT участвовать в проверке тождества строки,
только если у неё обе колонки оборотов за период пусты и на листе есть другая строка TOTAL: перед «Итого» она несёт
одни развёрнутые сальдо (R-16, уточнён R-19). Вторая итоговая строка с оборотами (в 1С она бывает и после «Итого», с
оборотами) и единственная итоговая строка листа проверяются тождеством как обычные; основная «Итого» проверяется как
прежде.
Исключение — **счета без сальдо по аналитике** (решение владельца 07.10.2026, OQ-132): у 90, 91 и 99 строки аналитики несут
только обороты, сальдо закрыто на субсчёт закрытия (91.09). Список счетов — данные `rules/osv_checks.yaml`
(`accounts_without_analytic_balance`, состав `90`, `91`, `99`; счёт 90 добавлен решением владельца 19.10.2026). Строка относится к такому счёту, если
код ближайшего счёта её ветки (`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=…` нет
#### Scenario: «Итого развернутое» перед «Итого»
- **WHEN** разбирается синтетическая ОСВ `s42` (субсчета 76.2, 76.5, 76.Н.1 на уровне 0, затем «Итого развернутое» и
«Итого», «Итого» — свёрнутое нетто)
- **THEN** обе итоговые строки — TOTAL уровня 0 без родителя, «Итого развернутое» не ребёнок «76.Н.1»;
`total_row_matches_root = true` (сверка по «Итого»), нарушений нет, `status = done`
#### Scenario: «Итого развернутое» без оборотов
- **WHEN** у «Итого развернутое» заполнены только сальдо (обороты пусты), а у основной «Итого» тождество строки выполняется
- **THEN** `balance_identity_violations` не содержит «Итого развернутое», `status = done`; при нарушении тождества у основной
«Итого» её `row_number` в списке остаётся
#### Scenario: «Итого развернутое» с оборотами
- **WHEN** «Итого развернутое» стоит после «Итого», у неё заполнены обороты, и сальдо на конец не сходится с началом и оборотами
- **THEN** её `row_number` попадает в `balance_identity_violations`; при сходящемся тождестве нарушения нет
#### Scenario: Показатели второй итоговой строки без оборотов
- **WHEN** колонка «Показатели», «Итого развернутое» без оборотов (до или после «Итого») и под ней строка «НУ» без оборотов,
на листе есть основная «Итого»
- **THEN** строка «НУ» блока пропускает тождество строки так же, как «Итого развернутое»; `balance_identity_violations`
пуст, `status = done`; строка «НУ» под последней строкой данных в итоговый блок не входит
#### Scenario: «Итого» плоского списка
- **WHEN** разбирается синтетическая ОСВ `s40` («Итого» = сумма контрагентов, сальдо «Итого» свёрнуто)
- **THEN** `total_row_matches_root = true`, `status = done`; если сумма одного контрагента испорчена — `false`,
`OSV_TOTAL`, `needs_check`
### Requirement: Форматы, лимиты, время
Парсер SHALL читать XLSX (openpyxl, полное чтение), XLS (xlrd, `formatting_info`), XLSB (pyxlsb; outline level недоступен —
каскад начинается с отступа). Имена частей xlsx SHALL сравниваться без учёта регистра, как в Excel (AURORA-1707): часть,
отличающаяся от ссылки `[Content_Types].xml` (`Override PartName`) или `*.rels` (`Target` внутренней связи) только
регистром, читается под именем из ссылки; две части под одну ссылку не угадываются. Несоответствие `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: Имя части xlsx в другом регистре
- **WHEN** таблица строк лежит в архиве как `xl/SharedStrings.xml`, а ссылки ведут на `xl/sharedStrings.xml` (или лист
`Sheet1.xml` при ссылке на `sheet1.xml`)
- **THEN** книга читается так же, как с именами из ссылок; не `UNREADABLE` и без потери листа
#### 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: Словари — данные, не код
Признаки листов, синонимы групп колонок и подзаголовков, колонки ИНН, строки «Итого», маркеров шапки (`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`, `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»
### Requirement: Строки ИНН рядом с контрагентом
Между построением иерархии и классификацией парсер SHALL найти строки ИНН и сопоставить каждую с её строкой-наименованием
в любой из восьми раскладок: ИНН над / под наименованием × в дереве (родитель или ребёнок наименования) / не в дереве
(сосед того же родителя) × с суммами / без сумм.
**Строка ИНН** — не счёт, не «Итого», не строка Вал./Кол., подпись которой целиком — ИНН по словарю `inn_only_row`
(10 или 12 цифр числом или текстом, «ИНН 7701234567», «ИНН: 7701234567 / 770101001»); числовая ячейка из 9/11 цифр
дополняется нулём слева (R-24, #34). Без подсказки `<группировка>.ИНН` в шапке ИНН MUST пройти контрольную сумму ИНН
(алгоритм ФНС); строка из 10/12 цифр с неверной контрольной суммой рядом с наименованием MUST NOT считаться строкой ИНН:
она классифицируется как обычно, в `warnings` — «строка N: похожа на ИНН X, но контрольная сумма не сходится — строка
оставлена как есть», документ — `needs_check`. С подсказкой контрольная сумма не проверяется, а пустая подпись строки-ребёнка наименования — строка ИНН
«ИНН не указан» (склейка без ИНН, без `needs_check`, R-1 #32). **Наименование** — строка не счёт, не «Итого», не Вал./Кол.,
не строка ИНН. С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью, которая не
ИНН, — строка ИНН с нераспознанным ИНН (без пары, «ИНН не распознан»); такой ребёнок бывает только у наименования, которое
по схеме — COUNTERPARTY (договоры под ним остаются договорами, design #22); при другой позиции по схеме (подразделение и
т. д.) ребёнок — подлинное наименование; если позиции по схеме нет, ребёнком MUST быть только строка без детей (лист,
design #14).
**Пара строки ИНН** (связь в дереве важнее соседства):
- у строки ИНН есть дети → пара — её единственный ребёнок-наименование; несколько детей — пары нет. Если в
`group_schema` больше одной группировки, ни одна строка листа (кроме Вал./Кол.) не стоит глубже схемы и в шапке нет
подсказки `<группировка>.ИНН`, строка ИНН не склеивается и без предупреждения классифицируется по схеме (design #16);
- родитель строки ИНН — наименование → пара — родитель; несколько строк ИНН у одного наименования — пары нет. Пара —
родитель только при подсказке «под» или если родитель по схеме — COUNTERPARTY (или позиция вне схемы); иначе у строки
ИНН с детьми действует правило «есть дети», а без детей она — сосед (следующий пункт). Строка ИНН с детьми под
наименованием при подсказке «над» и родителе-контрагенте по схеме остаётся у родителя (дети — договоры), а
противоречие подсказке подсвечивается (design #23);
- иначе — непосредственный сосед того же родителя: при суммах — сосед с теми же суммами (совпали оба — по направлению);
без сумм — сосед по направлению листа (над → следующий, под → предыдущий).
Если у строки ИНН есть суммы, все шесть MUST совпасть с суммами пары (пустая ячейка = 0), иначе пары нет. **Направление
листа** — подсказка шапки (порядок `<группировка>.ИНН`), если она есть; иначе сведения пар соседей по суммам и однозначных
блоков детей одного родителя из несопоставленных строк ИНН и наименований (начинается строкой ИНН — над; кончается строкой
ИНН — под; начинается и кончается — блок сломан, его строки ИНН без пары) берутся вместе; если они противоречат друг другу
или их нет — направление неизвестно, строки ИНН без сумм, которым оно нужно, остаются без пары. Две строки ИНН с одной парой, прошедшие проверку сумм, — обе без пары (design #26).
**Склейка:** строка ИНН MUST NOT попадать в `rows`, её ИНН — наименованию (источник 2 требования «Классификация строк»),
её дети переходят к наименованию, при «над, в дереве» наименование с поддеревом поднимается на её место; в `warnings` —
«строка N: ИНН … отнесён к контрагенту в строке M». ИНН колонки ИНН у наименования, отличный от ИНН строки, остаётся,
в `warnings` — расхождение, документ — `needs_check`.
**Противоречие подсказке шапки:** если пара найдена, но фактическая раскладка противоречит направлению подсказки
`<группировка>.ИНН` (подсказка «над», а строка ИНН — ребёнок наименования или сосед ниже него по суммам; подсказка «под»,
а строка ИНН — родитель наименования или сосед выше него по суммам), строка ИНН MUST склеиваться как обычно, а в
`warnings` MUST быть отдельное предупреждение «строка N: ИНН … стоит <под/над> наименованием в строке M, а шапка
(«<группировка>.ИНН») указывает <над/под> — проверьте»; документ — `needs_check`. Для соседей без сумм направление берётся
из подсказки, конфликта нет. Раскладка, совпадающая с подсказкой (в том числе «под, в дереве» в 1С), предупреждения не даёт.
**Страховка после классификации:** ИНН склеен со строкой, которая по схеме не стала `COUNTERPARTY`, — в `warnings` «строка N:
ИНН … отнесён к строке M, но она не контрагент — проверьте», документ — `needs_check` (design #24).
**Без пары:** строка ИНН остаётся в `rows` с `group_type = OTHER` (в свод ядра не попадает), в `warnings` — «строка N:
ИНН … без пары — <причина>», документ — `needs_check`. Структура под склейку MUST NOT подгоняться.
#### Scenario: Восемь раскладок без подсказки
- **WHEN** разбираются синтетические ОСВ `s20`…`s27` (по одной на раскладку; ИНН с верной контрольной суммой; шапка без
`Контрагенты.ИНН`)
- **THEN** у каждого контрагента `counterparty_inn` — ИНН его строки ИНН, строк ИНН в `rows` нет, контрагентов столько же,
сколько наименований, контрольные суммы сходятся, статус `done`
#### Scenario: ИНН под наименованием не уходит следующему контрагенту
- **WHEN** в синтетической ОСВ `s27` (ИНН под наименованием, не в дереве, без сумм) идут «ООО Альфа», ИНН Альфы,
«ООО Бета», ИНН Беты
- **THEN** ИНН Альфы — у «ООО Альфа», ИНН Беты — у «ООО Бета»
#### Scenario: ИНН над наименованием (OQ-130)
- **WHEN** разбирается синтетическая ОСВ `s18` (строка ИНН без сумм над наименованием, ИНН с верной контрольной суммой)
- **THEN** ИНН — у контрагента под строкой, строк ИНН в `rows` нет, статус `done`
#### Scenario: Реквизит «Контрагенты.ИНН» (OQ-162)
- **WHEN** разбирается `osv-dkb/novopetrovskaya/2025-Q3/60_ 9мес.2025г.xls`
- **THEN** результат совпадает с golden до изменения: 713 из 716 контрагентов с ИНН (включая ИНН с неверной контрольной
суммой), 3 физлица без ИНН, статус `done`
#### Scenario: Неверная контрольная сумма без подсказки
- **WHEN** в синтетической ОСВ без подсказки над наименованием строка «7701234567» (контрольная сумма не сходится)
- **THEN** строка не склеивается, в `warnings` — «похожа на ИНН, контрольная сумма не сходится», статус `needs_check`
#### Scenario: Опечатка в ИНН среди верных (s28)
- **WHEN** разбирается синтетическая ОСВ `s28` (раскладка `s20`, но у ООО Альфа строка ИНН «7701234567» с неверной
контрольной суммой; шапка без подсказки)
- **THEN** строка «7701234567» остаётся обычной строкой, у ООО Альфа `counterparty_inn` нет, в `warnings` — «строка N
похожа на ИНН 7701234567, но контрольная сумма не сходится», остальные контрагенты получают свои ИНН (направление «над» —
по блоку 60.02), статус `needs_check`
#### Scenario: Раскладка противоречит подсказке шапки
- **WHEN** в шапке `Контрагенты.ИНН` («над»), а строка ИНН без детей стоит под родителем-контрагентом (или наоборот:
подсказка «под», строка ИНН — родитель наименования)
- **THEN** строка ИНН склеена с этим наименованием, в `warnings` — предупреждение «стоит под (над) наименованием … а шапка
… указывает над (под) — проверьте», статус `needs_check`
#### Scenario: Ведущий ноль у числовой строки ИНН
- **WHEN** строка ИНН без подсказки — числовая ячейка `123456788` (9 цифр; `0123456788` проходит контрольную сумму)
- **THEN** `counterparty_inn = "0123456788"`
#### Scenario: Неоднозначное направление
- **WHEN** под счётом блок «наименование, ИНН без сумм, наименование» и других сведений о направлении нет
- **THEN** строка ИНН без пары, `group_type = OTHER`, предупреждение «направление неизвестно», статус `needs_check`
#### Scenario: Несколько детей у строки ИНН
- **WHEN** у строки ИНН в дереве два ребёнка-наименования
- **THEN** строка ИНН без пары, `group_type = OTHER`, статус `needs_check`
### Requirement: Строки-показатели и колонка «Показатели»
Строка-показатель — строка, которая несёт вид сумм предыдущей строки данных, а не уровень аналитики. Парсер SHALL считать
строку показателем, если:
1. в таблице есть колонка «Показатели» (заголовок блока по словарю `osv_headers.yaml` — `service_columns.indicator`,
«Показатели», «Показа-тели»), ячейка строки в ней непуста и метка не «БУ»: «НУ» → `data_type = NU`, «Кол.», «Кол. доп.» →
QUANTITY, «Вал.» → CURRENCY (словарь `data_types.yaml` — `indicator_column`); «ПР», «ВР» (`indicator_column_untyped`)
→ `data_type = null`; метка не из словарей → `data_type = null` и предупреждение `OSV_ROWS` «строка N: метка «X» в
колонке «Показатели» не распознана — строка не участвует в проверках»;
2. иначе — текст строки матчит `data_types.yaml` — `row` («Вал.», «Кол.», «Кол-во», «Кол-во, <единица>», «В валюте» → CURRENCY/QUANTITY) или
равен коду валюты из `currency_codes`.
Строка «Валюта <код>» («Валюта USD») MUST NOT считаться показателем: это строка аналитики с рублёвыми суммами; код валюты
не хранится. Колонка «Показатели» MUST NOT быть колонкой текста строки (как колонка ИНН) и SHALL оставаться в `extra`
строки под своим заголовком. `row_value` строки с меткой колонки — сама метка.
Строки-показатели MUST NOT передаваться в каскад иерархии. Родитель строки-показателя — ближайшая строка выше, которая
сама не показатель; уровень — на один глубже родителя; показатель без строки данных выше — уровень 0 без родителя.
Строки-показатели MUST NOT входить в сверку «сумма детей = родитель» (ни как дети, ни как родители) и в сверку «Итого»;
тождество строки (начало + обороты = конец) проверяется у них, как у всех строк. Строки NU хранятся как пришли
(`ASSUMPTION(OQ-45)`); собственных проверок по NU нет (AURORA-1669).
Итоговые строки, следующие сразу за основной «Итого» и помеченные в колонке «Показатели» меткой показателя, SHALL
оставаться в таблице как TOTAL (`data_type` по метке) и MUST NOT уходить в подвал или давать предупреждение «ниже
таблицы есть числа»; сверка «Итого = корень» берёт первую итоговую строку, которая не показатель.
#### Scenario: Колонка «Показатели» БУ/НУ
- **WHEN** разбирается синтетическая ОСВ `s33` (счёт 02, шапка без «Выводимые данные», колонка «Показа-тели», на каждую
строку — «БУ» и «НУ», название объединено на две строки, «Итого» БУ и НУ)
- **THEN** строки «БУ» — `data_type = BU`, строки «НУ» — `data_type = NU`, `row_value = "НУ"`, `group_type = OTHER`,
родитель — строка «БУ» над ней; `parent_sum_violations = []`, `total_row_matches_root = true`, две строки TOTAL
(BU и NU), предупреждения «ниже таблицы» и «вид данных … приняты как БУ» нет, статус `done`
#### Scenario: Количество, валюта и разницы в колонке «Показатели»
- **WHEN** разбирается синтетическая ОСВ `s34` (колонка «Показатели» с метками «Кол.», «Вал.», «ПР», «ВР» и одной
неизвестной меткой)
- **THEN** «Кол.» — QUANTITY, «Вал.» — CURRENCY, «ПР»/«ВР»/неизвестная — `data_type = null`, все — показатели вне
сверки родителя; одно предупреждение `OSV_ROWS` о нераспознанной метке; статус `done`
#### Scenario: «В валюте» под «Валюта <код>»
- **WHEN** разбирается синтетическая ОСВ `s35` (под контрагентом пары «Валюта USD» / «В валюте», «Валюта EUR» /
«В валюте»)
- **THEN** «В валюте» — CURRENCY, родитель — «Валюта …» над ней; контрагент = Σ строк «Валюта …» по рублям;
статус `done`
#### Scenario: Строки «Вал.»/«Кол.» в колонке текста не меняются
- **WHEN** разбираются s04, s30 и все реальные фикстуры `../fixtures/osv/**`
- **THEN** golden-снимки совпадают с прежними (родитель показателя — строка над ним, как и было)