Andrey Epifancev 64f836d5a6 openspec: архив osv-inn-rows-layouts (#35)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:25:43 +04:00

164 lines
33 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.

## Context
Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs/context/05-domain-rules.md` §1.1 (реквизит
`<группировка>.ИНН`, OQ-162) и §1.3 (ИНН контрагента: колонка → строка ИНН → хвост названия; OQ-100, OQ-130); новое правило
заносится в §1.3 и новым OQ в `07-open-questions.md`.
Восемь раскладок (ИНН над / под наименованием) × (в дереве / не в дереве) × (с суммами / без сумм). Поведение main на
синтетике без подсказки в шапке:
| Раскладка | main |
|---|---|
| над, не в дереве, без сумм | склеивается (OQ-130) |
| над, не в дереве, с суммами | ИНН — отдельный контрагент, `needs_check` |
| над, в дереве, с суммами | ИНН вместо наименования, `done` |
| над, в дереве, без сумм | `needs_check` |
| под, в дереве, с суммами | ИНН теряется, `done` (с подсказкой в шапке — склеивается, #32) |
| под, в дереве, без сумм | `needs_check` |
| под, не в дереве, с суммами | ИНН — отдельный контрагент, `needs_check` |
| под, не в дереве, без сумм | **ИНН первого контрагента у второго, `done`** |
Проверено при проектировании: стратегии иерархии `outline`, `indent`, `account_pattern` ставят строку ИНН (с суммами и
без) на тот же уровень, что и наименования (OQ-130 отключался), то есть дерево до склейки корректно; стратегия `sums` не
проверялась. Контрольная сумма ИНН на golden: реальные ИНН (≈ 5 852) верны, кроме трёх (физлица: Цимлянский, ОСВ 76,
`611801003692`; Новопетровская `233370449085`; НПЗ `614002279560`), вся синтетика (`5001234567`, `7701234567`,
`7812345678` и др.) неверна.
## Decisions
Решения владельца в brainstorming 13.10.2026:
1. **Подход A — один проход по дереву.** Новый модуль `api/src/parser/inn_rows.py` работает между `resolve_levels` и
`classify_rows`. Он заменяет `_glue_inn_rows` (OQ-130, до иерархии) и `glue_inn_attributes` (#32, после
классификации). Классификатор получает дерево без строк ИНН и ИНН для строк-наименований.
2. **Контрольная сумма.** Без подсказки в шапке строка — строка ИНН только при верной контрольной сумме ИНН (алгоритм ФНС:
10 цифр — весовые коэффициенты 2, 4, 10, 3, 5, 9, 4, 6, 8; 12 цифр — два контрольных разряда, 7, 2, 4, 10, 3, 5, 9, 4,
6, 8 и 3, 7, 2, 4, 10, 3, 5, 9, 4, 6, 8), во всех восьми раскладках, включая прежнюю OQ-130. С подсказкой
`<группировка>.ИНН` контрольная сумма не проверяется (файл банка #32 не меняется).
3. **Неверная контрольная сумма.** Строка из 10/12 цифр (по `inn_only_row`) рядом с наименованием, не прошедшая
контрольную сумму без подсказки, — не строка ИНН: классифицируется как обычно, предупреждение «строка N похожа на ИНН
…, контрольная сумма не сходится», документ `needs_check`.
4. **Ведущий ноль (#34).** Числовая ячейка строки ИНН из 9/11 цифр дополняется нулём слева (как R-24 для колонки),
контрольная сумма проверяется после дополнения.
5. **«Над, в дереве»** — склейка только с единственным ребёнком; несколько детей — не склеивать, `needs_check`.
6. **Направление для соседей** (предложено в дизайне, не оспорено): пары с суммами — по совпадению сумм; без сумм — по
направлению документа: порядок в шапке → пары по суммам → однозначные блоки. Противоречие или нет однозначных блоков —
не склеивать, `needs_check`.
7. **«Под, в дереве»** — несколько строк ИНН под одним наименованием — `needs_check` (как #32).
8. **Строка ИНН — только ИНН**: 10/12 цифр числом или текстом, «ИНН …», «ИНН/КПП» (`inn_only_row`); счёт строкой ИНН не
бывает.
9. **Синтетика для отказа «неверная контрольная сумма»** (решение владельца 13.10.2026, H-1): «синтетика для отказа
«неверная контрольная сумма» — да». Файл `s28_inn_bad_checksum.xlsx`: раскладка `s20` («над, не в дереве, без сумм»),
у ООО Альфа строка ИНН `7701234567`; ожидание — строка остаётся обычной, ИНН у Альфы нет, предупреждение «похожа на ИНН»,
`needs_check`, остальные контрагенты получают свои ИНН (направление — по блоку 60.02).
10. **Противоречие раскладки и подсказки шапки** (решение владельца 13.10.2026, H-2): «Противоречие раскладки и подсказки
шапки — СКЛЕИВАТЬ, НО ПОДСВЕЧИВАТЬ». Пара найдена, но ИНН стоит не там, где указывает подсказка `<группировка>.ИНН`
(в дереве или соседом по суммам) — склейка как обычно, отдельное предупреждение «строка N: ИНН … стоит <под/над>
наименованием в строке M, а шапка («…») указывает <над/под> — проверьте», `ok = False`, `needs_check`. Закрывает T2.12.
### Алгоритм `inn_rows.py`
Вход: строки `BodyRow`, `Levels` (уровень и родитель), подсказка шапки (`InnHint`: есть ли `<группировка>.ИНН` и
направление по порядку элементов), схема группировок `group_schema` (для защиты, п. «Защита» ниже), словари. Выход: строки и
`Levels` без склеенных строк ИНН, ИНН для строк-наименований (`row → inn`), множество строк ИНН без пары (классификатор
ставит им `OTHER`), предупреждения, признак «всё склеено».
1. **Кандидаты.** Строка ИНН — не счёт, не «Итого», не строка Вал./Кол.; подпись совпадает с `inn_only_row` (числовая
ячейка из 9/11 цифр — с ведущим нулём); без подсказки ИНН проходит контрольную сумму (иначе п. 3 решений). С
подсказкой пустая подпись строки-ребёнка наименования (только положение «под, в дереве») — тоже кандидат, «ИНН не
указан» (R-1 #32).
2. **Наименование** — строка не счёт, не «Итого», не Вал./Кол., не кандидат.
3. **Положение кандидата** (по `Levels`):
- есть дети → **над, в дереве**: пара — единственный ребёнок-наименование; он и его поддерево поднимаются на уровень
вверх, родителем становится родитель кандидата;
- родитель — наименование → **под, в дереве**: пара — родитель; дети кандидата переходят к родителю;
- иначе (родитель — счёт или корень, детей нет) → **не в дереве**: пара — сосед того же родителя, непосредственно
выше или ниже.
Связь в дереве важнее соседства.
4. **Суммы.** Если у кандидата есть суммы (`_has_sums`), шесть значений MUST совпасть с суммами пары (`None` = 0); иначе
пары нет.
5. **Соседи с суммами**: пара — сосед-наименование с равными суммами; совпали оба соседа — по направлению документа.
6. **Направление документа** (лист ОСВ): подсказка шапки (п. 1), если она есть; иначе сведения пп. 2–3 вместе:
1. подсказка шапки: `<группировка>.ИНН` после группировки — под; перед — над;
2. пары соседей, найденные по суммам (п. 5, один совпавший сосед);
3. блоки: последовательность детей одного родителя из кандидатов «не в дереве» (I) и наименований (N), ещё не
сопоставленных; начинается с I и не кончается I — над; кончается I и не начинается I — под; начинается и кончается I
— блок сломан (его кандидаты без пары); ни то ни другое — нет сведений.
Сведения пп. 2–3 противоречат друг другу или их нет — направление неизвестно.
7. **Соседи без сумм**: пара — соседнее наименование по направлению (над → следующий сосед, под → предыдущий).
Направление неизвестно — пары нет.
8. **Конфликты**: два кандидата претендуют на одно наименование — оба без пары. ИНН в колонке ИНН у наименования отличается
от ИНН строки — колонка побеждает, предупреждение, `needs_check`. ИНН в хвосте названия — строка ИНН побеждает
(прежний порядок §1.3).
9. **Склейка**: строка ИНН уходит из строк и `Levels`, ИНН — наименованию, предупреждение «строка N: ИНН … отнесён к
контрагенту в строке M» (пустой при подсказке — «ИНН пуст … без ИНН»). Если раскладка противоречит подсказке шапки
(пара выше/ниже, чем указывает подсказка) — следом отдельное предупреждение о противоречии, `ok = False` (решение владельца 10, H-2).
10. **Без пары**: строка остаётся в `rows`, `group_type = OTHER` (в свод не попадает, суммы не задваиваются, как #32),
предупреждение «строка N: ИНН … без пары — <причина>», признак «всё склеено» = `False` → документ `needs_check`.
**Защита «над, в дереве».** Контрагент, названный ИНН, с одним договором неотличим по форме от строки ИНН над
контрагентом. Проверка — по листу: если `group_schema` содержит больше одной группировки и ни одна строка листа (кроме
Вал./Кол.) не стоит глубже схемы (позиция — число не-счётных предков после последнего счёта + 1 — не больше
`len(group_schema) − 1`), лишнего уровня в листе нет: кандидаты «над, в дереве» не склеиваются, без предупреждения
классифицируются по схеме. Схема из одного ACCOUNT — проверки нет, по пп. 1–10.
**Нераспознанный реквизит при подсказке «под».** С подсказкой «под» (#32) единственный ребёнок наименования с теми же
суммами, подпись которого не ИНН и не пуста («нерез.»), — строка реквизита с нераспознанным ИНН: без пары, `OTHER`,
«ИНН не распознан», `needs_check` (поведение #32 сохраняется).
### Изменения в коде
- `inn_rows.py` — новый модуль (пп. 1–10); контрольная сумма ИНН — `numbers.py` (`inn_checksum_ok`).
- `header.py`: `InnAttribute` и позиции-реквизиты удаляются; `GroupSchema` получает подсказку (`inn_hint`: владелец и
направление); `<группировка>.ИНН` распознаётся и **перед** группировкой-владельцем (сейчас только после).
- `classify.py`: удаляются `_glue_inn_rows`, `glue_inn_attributes`, `_not_glued_reason`, поля `is_inn_attribute`,
`attribute_inn`; `_classify_row` берёт ИНН строки из результата `inn_rows` вместо `inn_above` и ставит `OTHER`
строкам без пары.
- `hierarchy.py`: поле `BodyRow.inn_above` удаляется.
- `osv.py`: вызов `inn_rows` между `resolve_levels` и `classify_rows`; признак «всё склеено» и предупреждения — в статус и
`warnings`.
### Тесты
- Юнит-тесты `inn_rows.py` на деревьях вручную: распознавание (10/12, число/текст, «ИНН …», «ИНН/КПП», ведущий ноль),
контрольная сумма с подсказкой и без, все отказы пп. 4–8 и 10, защита «над, в дереве», направление (шапка, суммы,
блоки, противоречие, нет сведений, сломанный блок), расхождение с колонкой, R-1.
- Синтетика `s20`…`s27` (по одной на раскладку, ИНН с верной контрольной суммой, шапка без подсказки), golden — после
ручной проверки: ИНН у всех контрагентов, итоги сходятся, `done`.
- `s18`: ИНН с верной контрольной суммой (только в `s18`, общие узлы генератора не меняются).
- Сравнение результатов main и ветки на всех файлах fixtures и синтетике; ожидаемые отличия — `s18`, новые `s20`…`s27`,
файлы со строкой ИНН с неверной контрольной суммой; Новопетровская без изменений. Каждое отличие согласуется с
владельцем до `make golden-update`.
## Decisions made without discussion
| # | Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|---|
| 1 | 13.10.2026 | Защита «над, в дереве»: при схеме из двух и более группировок склейка только если в листе есть строки глубже схемы (проверка по листу, не по поддереву) | Контрагент, названный ИНН, с одним договором иначе потерял бы договор (договор стал бы контрагентом) | При ветке глубже схемы контрагент, названный ИНН, с одним договором отдаёт ИНН договору без `needs_check` (защита работает только без подсказки и только когда лист не глубже схемы); файл с подсказкой-схемой, где 1С не добавила уровень, — строка ИНН без пары, `needs_check`; правка условия |
| 2 | 13.10.2026 | Строка ИНН без пары — `group_type = OTHER` (в `rows`, не в своде) | Как #32: суммы строки ИНН с суммами иначе задвоились бы в своде ядра; документ всё равно на проверке | Аналитик не видит такую строку контрагентом и сопоставляет вручную; поменять тип — одна строка |
| 3 | 13.10.2026 | ИНН колонки расходится с ИНН строки — побеждает колонка, `needs_check` | Колонка — явный реквизит отчёта, строка — вывод по соседству | Неверный ИНН у контрагента до проверки аналитиком; документ на проверке |
| 4 | 13.10.2026 | Направление определяется на лист ОСВ (документ), не на файл и не на счёт | Один лист — один отчёт 1С с одной настройкой вывода | Смешанные раскладки в одном листе — `needs_check` вместо склейки |
| 5 | 13.10.2026 | Подсказка шапки задаёт направление сильнее пар по суммам; пары по суммам склеиваются независимо от направления | Суммы однозначно указывают пару; шапка — явная настройка отчёта | Противоречие шапки и сумм не поднимается как ошибка |
| 6 | 13.10.2026 | Суммы сравниваются с `None` = 0 (в #32 — точное равенство кортежей) | Пустая ячейка и 0 в ОСВ 1С значат одно и то же | Пара по суммам у строк, различающихся только пустотой ячейки; на Новопетровской не влияет (проверяется golden) |
| 7 | 13.10.2026 | Соседом считается только непосредственный сосед в списке детей того же родителя | Иначе сопоставление через чужие строки неоднозначно | ИНН через строку от наименования не склеивается, `needs_check` |
| 8 | 13.10.2026 | Пустая подпись — кандидат только с подсказкой и только в положении «под, в дереве» (R-1 #32) | Без подсказки пустая строка ничего не значит; другое положение у пустого ИНН в 1С не встречалось | Пустая строка ИНН «над» при подсказке — остаётся наименованием без текста |
| 9 | 13.10.2026 | С подсказкой «под» единственный ребёнок наименования с теми же суммами и непустой подписью не-ИНН — нераспознанный реквизит: `OTHER`, `needs_check` | Сохраняет поведение #32 (тест «нерез.»): в 1С на этом месте стоит строка реквизита | Контрагент с одним договором с теми же суммами в файле с подсказкой «под», но без строки реквизита — `needs_check` вместо `done` |
| 10 | 13.10.2026 | Строка ИНН с детьми под наименованием: подсказка «под» — родителю (дети — договоры); иначе — родителю, если он по схеме `COUNTERPARTY` (при подсказке «над» — с подсветкой H-2, R-7, design #23), иначе ребёнку («над») | Иначе строка реквизита с договорами под ней (#32 со схемой «Контрагенты, Контрагенты.ИНН, Договоры» без подсказки) отдала бы ИНН договору; строка ИНН между подразделением и контрагентом — «над» | Файл без подсказки и без схемы, где ИНН под контрагентом и у строки ИНН есть дети, — ИНН уйдёт ребёнку; `needs_check` не ставится |
| 11 | 13.10.2026 | Task 1 (план): `__all__` модуля `numbers.py` дополнен `inn_checksum_ok` и `row_inn`; тело `inn_checksum_ok` при 12 цифрах — два булевых локала вместо одного `and`-выражения (ruff format не принял переносы из плана) | Публичные имена модуля экспортируются по соглашению файла; результат проверки не меняется | Нет: тест `test_inn_checksum_ok` покрывает оба разряда |
| 12 | 13.10.2026 | `inn_checksum_ok`: проверка «только цифры» через `isascii() and isdigit()`, не `isdecimal()` (ревью T1.2) | `isdecimal()` пропускает не-ASCII десятичные цифры, а ИНН — только `[0-9]`; `isdigit()` без `isascii()` пропускает «²» | Нет: ИНН с не-ASCII цифрами не встречаются; такая строка даёт `False` и не становится ИНН |
| 13 | 13.10.2026 | В `classify.py` локальная переменная `row_data_type` в `_classify_row` переименована в `data_type` (при публичной функции `row_data_type` иначе имя стало бы локальным и вызов упал с `UnboundLocalError`) | Переименование локальной переменной, поведение не меняется | Нет |
| 14 | 13.10.2026 | R-1 (design #14, T2.1): «нераспознанный реквизит» при подсказке «под» — только ребёнок-лист (без детей в дереве); уточнено R-6 (design #22) | Ребёнок со своими детьми — подлинное наименование со своей строкой ИНН; иначе единственный контрагент подразделения с теми же суммами терял ИНН (уходил в OTHER) | Реквизит «нерез.» с дочерними строками (в 1С не встречалось) станет наименованием, а не `needs_check` |
| 15 | 13.10.2026 | R-2 (design #15, T2.2): строка ИНН без детей под наименованием — пара родителю только при подсказке «под», либо если родитель по схеме COUNTERPARTY, либо схема не позволяет сказать (позиция вне схемы); иначе — сосед (`flat`) | Без этого ИНН соседа контрагента под подразделением отдавался подразделению; тот же помощник позиции в схеме, что в №10 | Файл без подсказки с ИНН-ребёнком подразделения и схемой, не совпадающей с реальностью, — строка в соседях, при неопределённом направлении без пары и `needs_check` |
| 16 | 13.10.2026 | R-3 (design #16, T2.3): защита «над, в дереве» (нет лишнего уровня) применяется только без подсказки в шапке | Подсказка `<группировка>.ИНН` — прямое свидетельство дополнительного уровня 1С | Подсказка «над» в шапке при реально отсутствующем уровне: контрагент, названный ИНН, склеится с ребёнком; документ — на проверку не попадает |
| 17 | 13.10.2026 | Направление листа без подсказки: сведения пар по суммам и блоков объединяются; противоречие — направление неизвестно (строки ИНН без пары). Спека («первое определённое») читается так же, как design §6 | Design §6 прямо задаёт «противоречат — неизвестно»; «первое» по порядку источников дало бы склейку, которой блоки противоречат | Лист с парами по суммам против блоков — строки ИНН без пары и `needs_check` вместо склейки по сумме; правка объединения — одна функция `_pair_flat` |
| 18 | 13.10.2026 | Task 4: параметризованный `test_header_status_and_hierarchy[s18_inn_row_above.xlsx]` (ожидает `done`) и golden s18 не правятся в Task 4 — оба падают только из-за ИНН s18 с неверной контрольной суммой и чинятся вместе с файлом в Task 5–6; в коммите упавшие тесты выведены из прогона (`--deselect`), `test_s18_…` помечен strict xfail | Бриф Task 4 прямо запрещает обновлять golden; ожидание `done` в параметризации относится к тому же файлу | Если Task 5 забудет снять xfail/починить параметризацию, strict xfail упадёт (XPASS) и напомнит |
| 19 | 13.10.2026 | H-2: направление фактической раскладки определяется по номерам строк пары (строка пары выше строки ИНН — ИНН «под», ниже — «над»), одинаково для пар в дереве и соседей; проверка выполняется только при подсказке и только для склеенных пар с непустым ИНН и без расхождения с колонкой ИНН | Номер строки однозначно отражает порядок на листе при любом пути поиска пары; пары без ИНН, без пары и с колонкой-победителем уже подсвечены своими предупреждениями | Нет: предупреждение лишь добавляется к уже состоявшейся склейке; результат склейки не меняется |
| 20 | 13.10.2026 | H-1: в s28 строка «7701234567» после классификации попадает в `rows` как контрагент (обычная строка по правилам классификатора) — тест фиксирует это «как есть» и проверяет её наличие среди контрагентов без ИНН | Спека: строка «классифицируется как обычно»; подгонки парсера нет | Нет: поведение соответствует ожиданию владельца |
| 21 | 13.10.2026 | Task 7: в tasks.md добавлены пункты 1.5 (H-2) и 2.2 (s28, H-1) сверх брифа; число golden в CLAUDE.md пересчитано по факту (123: 72 + 23 + 28, ранее значилось 113 = 72 + 22 + 19) | Бриф писался до H-1/H-2; счёт — по `find` | Нет: только документация |
| 22 | 13.10.2026 | R-6 / I-1 (design #22): `_unrecognised_attributes` — ребёнок-реквизит только у наименования, чья позиция по схеме COUNTERPARTY; при такой позиции условие «ребёнок — лист» снимается (строка «нерез.» с договорами → OTHER, договоры остаются договорами); позиция по схеме не COUNTERPARTY — ребёнок не реквизит; позиции нет — прежнее условие «лист» | Иначе (регрессия #32) единственный контрагент подразделения с теми же суммами получал «ИНН не распознан» | Контрагент с единственным договором тех же сумм при подсказке «под» без строки реквизита — `needs_check` (как в №9) |
| 23 | 13.10.2026 | R-7 / I-2 (design #23): `_owned_by_parent`: подсказка «под» — родитель; иначе родитель, если по схеме COUNTERPARTY; подсказка «над» не уводит ИНН ребёнку-договору | По спеке пара — родитель; противоречие подсказке ловит H-2 (предупреждение, `needs_check`) вместо молчаливой потери ИНН | Подсказка «над» и реальный ИНН над контрагентом, у которого в схеме родитель — контрагент: ИНН уходит родителю с предупреждением, `needs_check` |
| 24 | 13.10.2026 | R-8 / I-3 (design #24): страховка в `osv.py` после классификации: ИНН склеен со строкой, не ставшей COUNTERPARTY, — предупреждение «ИНН … отнесён к строке M, но она не контрагент — проверьте», `needs_check` | ИНН иначе пропадает молча (подразделение/договор не хранят ИНН) | Лишнее `needs_check` на редкой раскладке; склейка не отменяется |
| 25 | 13.10.2026 | R-9 / M-1 (design #25): `_without` — новый родитель = ближайший неудалённый предок по прежнему `levels.parent`, а не пересчёт `parents()` по уровням | Пропуск уровней outline давал неверного родителя | Нет: уровни (`by_row`) считаются как прежде |
| 26 | 13.10.2026 | R-10 / T2.5 (design #26): порядок `_drop_conflicts` после `_check_sums` оставлен; формулировка спеки: «две строки ИНН с одной парой, прошедшие проверку сумм, — обе без пары» | Строка, не прошедшая суммы, пары не претендует; сохранение порядка не меняет результаты golden | Две строки ИНН с одной парой, одна из которых не прошла суммы: вторая склеивается |
| 27 | 13.10.2026 | H.1 финального ревью отклонено как недостижимое: пустой кандидат бывает только «под, в дереве» и всегда склеивается с родителем, расхождение с колонкой для него невозможно (комментарий в `_result`). H.2: тест на сочетание «расхождение с колонкой + противоречие подсказке» — одно предупреждение о расхождении | Недостижимый код не добавляется | Нет |